feat: migrate to DragonflyDB and clean up environment configuration
- Replace Redis with DragonflyDB for better performance and memory efficiency - Remove redundant environment variables (POSTGRES_*, ENCRYPTION_KEY, OAUTH_SERVICE_URL) - Consolidate database configuration to use single DB_* variables - Use JWT_SECRET for both JWT tokens and encryption - Remove PORT variable redundancy, use BACKEND_PORT consistently - Clean up docker-compose configurations for dev/prod consistency - Add DragonflyDB configuration with optimized memory usage - Remove redis.conf as it's no longer needed - Update health checks to use Redis-compatible CLI for DragonflyDB - Add missing VITE_API_URL to production frontend - Fix GitHub Actions to use correct go.sum path - Clean up development directories and unused files
@@ -1,27 +1,51 @@
|
|||||||
# Server Configuration
|
# Server Configuration
|
||||||
FRONTEND_PORT=3000
|
FRONTEND_PORT=3000
|
||||||
BACKEND_PORT=8080
|
BACKEND_PORT=9000
|
||||||
DB_PORT=5432
|
|
||||||
DRAGONFLY_PORT=6379
|
|
||||||
GIN_MODE=debug
|
GIN_MODE=debug
|
||||||
|
|
||||||
|
# Demo Mode Configuration
|
||||||
|
# Set to true for demo mode (read-only with demo data)
|
||||||
|
# Set to false for normal mode (full functionality)
|
||||||
|
VITE_DEMO_MODE=true
|
||||||
|
VITE_API_URL=http://localhost:9000
|
||||||
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
OAUTH_SERVICE_URL=https://oauth.trackeep.org
|
||||||
|
VITE_OAUTH_SERVICE_URL=https://oauth.trackeep.org
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DB_TYPE=postgres
|
DB_TYPE=postgres
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=5432
|
DB_PORT=5433
|
||||||
DB_USER=trackeep
|
DB_USER=trackeep
|
||||||
DB_PASSWORD=your_password_here
|
DB_PASSWORD=your_password_here
|
||||||
DB_NAME=trackeep
|
DB_NAME=trackeep
|
||||||
DB_SSL_MODE=disable
|
DB_SSL_MODE=disable
|
||||||
|
|
||||||
# DragonflyDB Configuration
|
# DragonflyDB Configuration
|
||||||
DRAGONFLY_ADDR=dragonfly:6379
|
DRAGONFLY_ADDR=dragonfly:6380
|
||||||
|
DRAGONFLY_PORT=6380
|
||||||
DRAGONFLY_PASSWORD=your_dragonfly_password_here
|
DRAGONFLY_PASSWORD=your_dragonfly_password_here
|
||||||
|
|
||||||
# JWT Configuration (also used for encryption)
|
# JWT Configuration (also used for encryption)
|
||||||
|
# Generate a secure 64-character hex string using: openssl rand -hex 32
|
||||||
JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly
|
JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly
|
||||||
|
# Token expiration time (e.g., 24h, 1h, 30m, 7d)
|
||||||
JWT_EXPIRES_IN=24h
|
JWT_EXPIRES_IN=24h
|
||||||
|
|
||||||
|
# GitHub OAuth App Configuration
|
||||||
|
GITHUB_CLIENT_ID=your_github_client_id_here
|
||||||
|
GITHUB_CLIENT_SECRET=your_github_client_secret_here
|
||||||
|
GITHUB_REDIRECT_URL=http://localhost:9000/api/v1/auth/github/callback
|
||||||
|
|
||||||
|
# GitHub App Configuration (for repository installation + backups)
|
||||||
|
# App installation callback should point to:
|
||||||
|
# http://localhost:9000/api/v1/github/app/callback
|
||||||
|
GITHUB_APP_SLUG=trackeep
|
||||||
|
GITHUB_APP_ID=123456
|
||||||
|
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
|
||||||
|
GITHUB_BACKUP_ROOT=./data/github-backups
|
||||||
|
GITHUB_BACKUP_TIMEOUT=10m
|
||||||
|
|
||||||
# File Upload Configuration
|
# File Upload Configuration
|
||||||
UPLOAD_DIR=./uploads
|
UPLOAD_DIR=./uploads
|
||||||
MAX_FILE_SIZE=10485760
|
MAX_FILE_SIZE=10485760
|
||||||
@@ -29,13 +53,6 @@ MAX_FILE_SIZE=10485760
|
|||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
CORS_ALLOWED_ORIGINS=*
|
CORS_ALLOWED_ORIGINS=*
|
||||||
|
|
||||||
# Demo Mode Configuration
|
|
||||||
VITE_DEMO_MODE=false
|
|
||||||
|
|
||||||
# AI Services Configuration
|
|
||||||
SEARCH_API_PROVIDER=demo
|
|
||||||
SEARCH_RESULTS_LIMIT=10
|
|
||||||
|
|
||||||
# Auto Update Configuration
|
# Auto Update Configuration
|
||||||
AUTO_UPDATE_CHECK=false
|
AUTO_UPDATE_CHECK=false
|
||||||
UPDATE_CHECK_INTERVAL=24h
|
UPDATE_CHECK_INTERVAL=24h
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
# Trackeep Mobile App
|
|
||||||
|
|
||||||
React Native mobile application for Trackeep - productivity and knowledge management platform.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### ✅ Core Features Implemented
|
|
||||||
- **🔐 Authentication**: Login with email/password and GitHub OAuth
|
|
||||||
- **📱 Offline Support**: Full offline functionality with sync when online
|
|
||||||
- **📝 Content Management**: Bookmarks, Tasks, Notes, and Time Tracking
|
|
||||||
- **🔍 Search**: Unified search across all content types
|
|
||||||
- **⏱️ Time Tracking**: Built-in timer with task association
|
|
||||||
- **🎨 Modern UI**: Material Design with React Native Paper
|
|
||||||
- **📊 Dashboard**: Overview with stats and recent activity
|
|
||||||
|
|
||||||
### ✅ Mobile-Specific Features
|
|
||||||
- **Gesture Navigation**: Intuitive mobile navigation patterns
|
|
||||||
- **Push Notifications**: Task reminders and updates with permission management
|
|
||||||
- **Camera Integration**: Document scanning capability with permission handling
|
|
||||||
- **Voice Notes**: Audio recording for quick notes with speech-to-text
|
|
||||||
- **Background Sync**: Automatic data synchronization
|
|
||||||
- **Responsive Design**: Optimized for various screen sizes
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **React Native** 0.72.6
|
|
||||||
- **TypeScript** for type safety
|
|
||||||
- **React Navigation** for navigation
|
|
||||||
- **React Native Paper** for UI components
|
|
||||||
- **AsyncStorage** for local data persistence
|
|
||||||
- **Axios** for API communication
|
|
||||||
- **Vector Icons** for iconography
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
mobile-app/
|
|
||||||
├── src/
|
|
||||||
│ ├── components/ # Reusable UI components
|
|
||||||
│ ├── screens/ # Screen components
|
|
||||||
│ │ ├── auth/ # Authentication screens
|
|
||||||
│ │ ├── DashboardScreen.tsx
|
|
||||||
│ │ ├── BookmarksScreen.tsx
|
|
||||||
│ │ ├── TasksScreen.tsx
|
|
||||||
│ │ ├── NotesScreen.tsx
|
|
||||||
│ │ ├── TimeTrackingScreen.tsx
|
|
||||||
│ │ ├── SearchScreen.tsx
|
|
||||||
│ │ └── SettingsScreen.tsx
|
|
||||||
│ ├── services/ # Business logic and API
|
|
||||||
│ │ ├── AuthContext.tsx
|
|
||||||
│ │ ├── OfflineContext.tsx
|
|
||||||
│ │ └── api.ts
|
|
||||||
│ ├── navigation/ # Navigation configuration
|
|
||||||
│ ├── utils/ # Utility functions
|
|
||||||
│ │ ├── storage.ts
|
|
||||||
│ │ └── offlineSync.ts
|
|
||||||
│ └── types/ # TypeScript type definitions
|
|
||||||
├── android/ # Android-specific code
|
|
||||||
├── ios/ # iOS-specific code
|
|
||||||
└── package.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js 16+
|
|
||||||
- React Native CLI
|
|
||||||
- Android Studio (for Android development)
|
|
||||||
- Xcode (for iOS development)
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
1. Clone the repository:
|
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd Trackeep/mobile-app
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Install dependencies:
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
3. For iOS, install pods:
|
|
||||||
```bash
|
|
||||||
cd ios && pod install && cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running the App
|
|
||||||
|
|
||||||
#### Android
|
|
||||||
```bash
|
|
||||||
npm run android
|
|
||||||
```
|
|
||||||
|
|
||||||
#### iOS
|
|
||||||
```bash
|
|
||||||
npm run ios
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Start Metro Bundler
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
Create a `.env` file in the root directory:
|
|
||||||
|
|
||||||
```env
|
|
||||||
API_BASE_URL=http://localhost:8080/api
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Configuration
|
|
||||||
|
|
||||||
Update the API base URL in `src/services/api.ts` to match your backend server.
|
|
||||||
|
|
||||||
## Features Status
|
|
||||||
|
|
||||||
### ✅ Completed
|
|
||||||
- [x] Project setup and configuration
|
|
||||||
- [x] Authentication flow (email/password, GitHub)
|
|
||||||
- [x] Navigation structure
|
|
||||||
- [x] Core screens (Dashboard, Bookmarks, Tasks, Notes, Time Tracking, Search, Settings)
|
|
||||||
- [x] Offline data storage and sync
|
|
||||||
- [x] Modern UI with Material Design
|
|
||||||
- [x] TypeScript integration
|
|
||||||
- [x] API service layer
|
|
||||||
- [x] Push notification implementation with permission management
|
|
||||||
- [x] Camera integration for document scanning
|
|
||||||
- [x] Voice recording for notes with speech-to-text
|
|
||||||
- [x] Enhanced settings screen with mobile features
|
|
||||||
|
|
||||||
### 📋 Planned
|
|
||||||
- [ ] Biometric authentication
|
|
||||||
- [ ] Dark mode theme
|
|
||||||
- [ ] Widget support
|
|
||||||
- [ ] Apple Watch companion app
|
|
||||||
- [ ] Advanced analytics
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Code Style
|
|
||||||
|
|
||||||
The project uses TypeScript and follows React Native best practices. All components are functional components with hooks.
|
|
||||||
|
|
||||||
### State Management
|
|
||||||
|
|
||||||
- **Authentication**: React Context (AuthContext)
|
|
||||||
- **Offline Sync**: React Context (OfflineContext)
|
|
||||||
- **Local Data**: AsyncStorage with SQLite for complex queries
|
|
||||||
|
|
||||||
### API Integration
|
|
||||||
|
|
||||||
All API calls are centralized in `src/services/api.ts` with automatic token management and error handling.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
### Android Release Build
|
|
||||||
```bash
|
|
||||||
npm run build:android
|
|
||||||
```
|
|
||||||
|
|
||||||
### iOS Release Build
|
|
||||||
```bash
|
|
||||||
npm run build:ios
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch
|
|
||||||
3. Make your changes
|
|
||||||
4. Add tests if applicable
|
|
||||||
5. Submit a pull request
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License.
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
For support and questions, please open an issue in the repository.
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
# Mobile App Sync Testing Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
This guide helps you test the bi-directional synchronization between the Trackeep mobile app and web dashboard.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
1. **Backend Server**: Ensure your Trackeep backend is running
|
|
||||||
2. **Web Dashboard**: Access the web dashboard at `http://localhost:3000` (or your configured URL)
|
|
||||||
3. **Mobile App**: Run the React Native app using:
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
npm run android # or npm run ios
|
|
||||||
```
|
|
||||||
|
|
||||||
## First Launch Setup
|
|
||||||
|
|
||||||
1. **Server Configuration**: On first launch, the mobile app will show the server setup screen:
|
|
||||||
- Enter your backend URL (e.g., `http://localhost:8080`)
|
|
||||||
- Enter your credentials
|
|
||||||
- Test connection before completing setup
|
|
||||||
|
|
||||||
2. **Authentication**: After setup, you'll be redirected to login with your existing credentials
|
|
||||||
|
|
||||||
## Testing Real-Time Sync
|
|
||||||
|
|
||||||
### Test 1: Create Content on Mobile, Verify on Web
|
|
||||||
|
|
||||||
1. **On Mobile App**:
|
|
||||||
- Open the Dashboard
|
|
||||||
- Tap the FAB (+) button
|
|
||||||
- Create a new task, bookmark, or note
|
|
||||||
- Verify it appears in the mobile dashboard
|
|
||||||
|
|
||||||
2. **On Web Dashboard**:
|
|
||||||
- Navigate to the corresponding section (Tasks, Bookmarks, or Notes)
|
|
||||||
- The new item should appear within seconds (if WebSocket is connected)
|
|
||||||
- If not, refresh the page to see the synced item
|
|
||||||
|
|
||||||
### Test 2: Create Content on Web, Verify on Mobile
|
|
||||||
|
|
||||||
1. **On Web Dashboard**:
|
|
||||||
- Create a new task, bookmark, or note
|
|
||||||
- Save the item
|
|
||||||
|
|
||||||
2. **On Mobile App**:
|
|
||||||
- The item should appear automatically if real-time sync is working
|
|
||||||
- Pull to refresh on the dashboard to force sync
|
|
||||||
- Check the specific section to verify the item appears
|
|
||||||
|
|
||||||
### Test 3: Offline Mode Testing
|
|
||||||
|
|
||||||
1. **Enable Offline Mode**:
|
|
||||||
- Turn off internet connection on your mobile device
|
|
||||||
- The app should show "🔴 Offline" status
|
|
||||||
|
|
||||||
2. **Create Content Offline**:
|
|
||||||
- Create several tasks, bookmarks, or notes
|
|
||||||
- Notice the pending changes counter increases
|
|
||||||
|
|
||||||
3. **Restore Connection**:
|
|
||||||
- Turn internet back on
|
|
||||||
- App should show "🟢 Connected" and auto-sync
|
|
||||||
- Verify items appear on web dashboard
|
|
||||||
|
|
||||||
### Test 4: Conflict Resolution
|
|
||||||
|
|
||||||
1. **Simulate Conflict**:
|
|
||||||
- Create the same item on both mobile and web while offline
|
|
||||||
- Bring both online simultaneously
|
|
||||||
- Verify how conflicts are resolved (last write wins or merge)
|
|
||||||
|
|
||||||
## Key Features to Test
|
|
||||||
|
|
||||||
### Real-Time Updates
|
|
||||||
- ✅ WebSocket connection status
|
|
||||||
- ✅ Instant updates across devices
|
|
||||||
- ✅ Connection recovery after disconnection
|
|
||||||
|
|
||||||
### Offline Support
|
|
||||||
- ✅ Offline data persistence
|
|
||||||
- ✅ Pending changes tracking
|
|
||||||
- ✅ Automatic sync when online
|
|
||||||
- ✅ Manual sync button
|
|
||||||
|
|
||||||
### Data Integrity
|
|
||||||
- ✅ All data types sync correctly (tasks, bookmarks, notes)
|
|
||||||
- ✅ Timestamps preserved
|
|
||||||
- ✅ User associations maintained
|
|
||||||
- ✅ Tags and metadata sync
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **WebSocket Connection Failed**:
|
|
||||||
- Check if backend WebSocket endpoint is accessible
|
|
||||||
- Verify firewall settings
|
|
||||||
- Check browser console for WebSocket errors
|
|
||||||
|
|
||||||
2. **Sync Not Working**:
|
|
||||||
- Verify server URL in mobile app settings
|
|
||||||
- Check authentication tokens
|
|
||||||
- Review backend logs for sync errors
|
|
||||||
|
|
||||||
3. **Offline Mode Not Detected**:
|
|
||||||
- Check network permissions on mobile device
|
|
||||||
- Verify NetInfo plugin is working
|
|
||||||
- Test with airplane mode
|
|
||||||
|
|
||||||
### Debug Tools
|
|
||||||
|
|
||||||
1. **Mobile App Debugging**:
|
|
||||||
```bash
|
|
||||||
# Enable debug mode
|
|
||||||
npx react-native log-android
|
|
||||||
npx react-native log-ios
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Backend Logs**:
|
|
||||||
- Monitor sync endpoint logs
|
|
||||||
- Check WebSocket connection logs
|
|
||||||
- Review database transaction logs
|
|
||||||
|
|
||||||
3. **Browser Console**:
|
|
||||||
- Monitor WebSocket connections
|
|
||||||
- Check for real-time update events
|
|
||||||
- Verify API responses
|
|
||||||
|
|
||||||
## Performance Testing
|
|
||||||
|
|
||||||
### Test Scenarios
|
|
||||||
|
|
||||||
1. **Large Dataset Sync**:
|
|
||||||
- Create 100+ items on one device
|
|
||||||
- Measure sync time to other device
|
|
||||||
- Verify no data loss
|
|
||||||
|
|
||||||
2. **Concurrent Updates**:
|
|
||||||
- Multiple users updating same data
|
|
||||||
- Test conflict resolution
|
|
||||||
- Verify data consistency
|
|
||||||
|
|
||||||
3. **Network Conditions**:
|
|
||||||
- Test on slow networks (2G/3G)
|
|
||||||
- Test with intermittent connectivity
|
|
||||||
- Verify sync resilience
|
|
||||||
|
|
||||||
## Expected Results
|
|
||||||
|
|
||||||
### Successful Sync Indicators
|
|
||||||
|
|
||||||
1. **Mobile App**:
|
|
||||||
- Status shows "🟢 Connected"
|
|
||||||
- Last sync time updates
|
|
||||||
- No pending changes counter
|
|
||||||
- Real-time updates received
|
|
||||||
|
|
||||||
2. **Web Dashboard**:
|
|
||||||
- New items appear without refresh
|
|
||||||
- WebSocket connection established
|
|
||||||
- No sync errors in console
|
|
||||||
|
|
||||||
### Performance Benchmarks
|
|
||||||
|
|
||||||
- **Small items** (< 1KB): Should sync within 1-2 seconds
|
|
||||||
- **Large items** (> 100KB): Should sync within 5-10 seconds
|
|
||||||
- **Batch sync** (50+ items): Should complete within 30 seconds
|
|
||||||
|
|
||||||
## Automated Testing
|
|
||||||
|
|
||||||
For comprehensive testing, consider implementing:
|
|
||||||
|
|
||||||
1. **Unit Tests**:
|
|
||||||
- Sync logic validation
|
|
||||||
- Offline queue management
|
|
||||||
- Conflict resolution
|
|
||||||
|
|
||||||
2. **Integration Tests**:
|
|
||||||
- End-to-end sync workflows
|
|
||||||
- WebSocket connection testing
|
|
||||||
- API integration validation
|
|
||||||
|
|
||||||
3. **E2E Tests**:
|
|
||||||
- Multi-device sync scenarios
|
|
||||||
- Offline/online transitions
|
|
||||||
- User interaction flows
|
|
||||||
|
|
||||||
## Reporting Issues
|
|
||||||
|
|
||||||
When reporting sync issues, include:
|
|
||||||
|
|
||||||
1. Device information (OS, version)
|
|
||||||
2. Network conditions
|
|
||||||
3. Steps to reproduce
|
|
||||||
4. Screenshots of error messages
|
|
||||||
5. Backend logs (if available)
|
|
||||||
6. Browser console errors
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
The sync implementation is considered successful when:
|
|
||||||
|
|
||||||
- ✅ All data types sync bi-directionally
|
|
||||||
- ✅ Real-time updates work within 5 seconds
|
|
||||||
- ✅ Offline mode functions correctly
|
|
||||||
- ✅ No data loss during sync
|
|
||||||
- ✅ Conflicts are handled gracefully
|
|
||||||
- ✅ Performance meets benchmarks
|
|
||||||
- ✅ Error recovery works reliably
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
|
||||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
|
||||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
|
||||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
|
||||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
|
||||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
|
||||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
|
||||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
|
||||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
|
||||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
|
||||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
|
||||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
|
||||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
|
||||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
|
||||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
|
||||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Trackeep",
|
|
||||||
"displayName": "Trackeep",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Productivity and knowledge management mobile app"
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
presets: ['module:metro-react-native-babel-preset'],
|
|
||||||
plugins: [
|
|
||||||
[
|
|
||||||
'module-resolver',
|
|
||||||
{
|
|
||||||
root: ['./src'],
|
|
||||||
extensions: ['.ios.js', '.android.js', '.js', '.ts', '.tsx', '.json'],
|
|
||||||
alias: {
|
|
||||||
'@': './src',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* Trackeep Mobile App
|
|
||||||
* React Native entry point
|
|
||||||
*/
|
|
||||||
import {AppRegistry} from 'react-native';
|
|
||||||
import App from './src/App';
|
|
||||||
import {name as appName} from './app.json';
|
|
||||||
|
|
||||||
AppRegistry.registerComponent(appName, () => App);
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
|
||||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
|
||||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
|
||||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
|
||||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
|
||||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
|
||||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
|
||||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
|
||||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
|
||||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
|
|
||||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
|
||||||
<path fill="#3b82f6" d="M797.56,348.33c30.91,6.66,83.06,31.07,87.21,66.41,3.15,26.85-13.06,79.89-19.48,108.33-18.08,80.05-35.49,169.43-58.24,247.51-37.84,129.87-259.97,143.84-369.81,131-79.98-9.34-191.79-42.13-218.72-127.8l-79.09-341.68c-6.97-50.68,49.68-73.96,89.21-85.61,54.67-16.12,110.4-16.62,167.08-15.62l-45.47,16.39c-44.91,18.53-101.62,26.35-145.23,45.5-15.84,6.96-26.94,19.89-14.58,36.36,18.56,24.72,115.14,56.1,146.77,62.89,82.16,17.65,152.89,2.18,231.43-22.46,54.98-17.25,130.36-44.86,168.52-89.18,25.27-29.35,31.42-69.3,44.38-104.84l-243.09,42.99-2,3.82c-1.45,15.08-2.76,30.11-6.03,44.92-31.75,143.69-186.99,93.71-283.19,53.84l1.47-2.15c82.39-23.86,163.75-50.91,245.03-78.2,5.91-3.48,5.08-25.81,7.22-33.55,3.45-12.47,12.22-19.65,23.97-24.08l282.88-49.24c14.15,3.11,20.4,11.87,18.9,26.37l-29.13,88.06v.02ZM841.24,457.54l-19.61,14.59c-113.51,75.99-355.25,76.96-485.87,53.37-58.02-10.48-91.56-24.33-143.96-48.22-1.22-.56-3.68-1.88-4.26-.11,9.51,23.92,8.86,56.35,22.36,78.1,28.12,45.3,113.96,67.69,163.71,76.52,72.61,12.88,148.98,14.98,222.29,7.73,63.62-6.3,204.67-30.25,226.3-101.29,7.88-25.88,11.16-54.64,19.03-80.69h0ZM806.3,607.51l-30.5,18.26c-131.73,66.26-405.38,65.51-535.38-4.91-6.49-3.51-12.31-8.25-18.62-11.96-1.12-.66-2.56-2.94-3.72-.65,11.55,32.54,8.2,71.49,34.17,96.87,59.84,58.47,212.94,70.07,292.78,66.97,63.99-2.48,191.09-21.71,233.65-73.56,19.02-23.17,17.88-63.15,27.62-91.02h0ZM774.26,744.37c-13.76,6.59-26.7,15.08-40.78,21.08-119.06,50.77-329.26,50.83-447.48-2.37-12.39-5.57-23.46-13.36-35.88-18.71,6.11,30.5,22.34,54.66,47.59,72.51,97.85,69.2,342.24,69.95,436.47-5.6,21.95-17.59,34.7-39.15,40.08-66.92h0Z"/>
|
|
||||||
<rect fill="#3b82f6" x="468.16" y="171.76" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="593.35" y="118.61" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
<rect fill="#3b82f6" x="361.93" y="250.32" width="53.15" height="53.15" rx="5.6" ry="5.6"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,21 +0,0 @@
|
|||||||
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
|
|
||||||
|
|
||||||
const defaultConfig = getDefaultConfig(__dirname);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
transformer: {
|
|
||||||
getTransformOptions: async () => ({
|
|
||||||
transform: {
|
|
||||||
experimentalImportSupport: false,
|
|
||||||
inlineRequires: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
resolver: {
|
|
||||||
alias: {
|
|
||||||
'@': './src',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = mergeConfig(defaultConfig, config);
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "trackeep-mobile",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Trackeep mobile app for productivity and knowledge management",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"android": "react-native run-android",
|
|
||||||
"ios": "react-native run-ios",
|
|
||||||
"start": "react-native start",
|
|
||||||
"test": "jest",
|
|
||||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
|
||||||
"build:android": "cd android && ./gradlew assembleRelease",
|
|
||||||
"build:ios": "react-native run-ios --configuration Release"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@react-native-async-storage/async-storage": "^1.19.5",
|
|
||||||
"@react-native-community/netinfo": "^11.4.1",
|
|
||||||
"@react-navigation/bottom-tabs": "^6.5.11",
|
|
||||||
"@react-navigation/drawer": "^6.6.6",
|
|
||||||
"@react-navigation/native": "^6.1.9",
|
|
||||||
"@react-navigation/native-stack": "^6.9.26",
|
|
||||||
"@react-navigation/stack": "^6.3.20",
|
|
||||||
"@types/react-native-push-notification": "^8.1.4",
|
|
||||||
"@types/react-native-vector-icons": "^6.4.18",
|
|
||||||
"axios": "^1.6.2",
|
|
||||||
"react": "18.2.0",
|
|
||||||
"react-native": "0.72.6",
|
|
||||||
"react-native-background-timer": "^2.4.1",
|
|
||||||
"react-native-camera": "^4.2.1",
|
|
||||||
"react-native-gesture-handler": "^2.13.4",
|
|
||||||
"react-native-keychain": "^8.1.3",
|
|
||||||
"react-native-paper": "^5.11.1",
|
|
||||||
"react-native-permissions": "^3.10.1",
|
|
||||||
"react-native-push-notification": "^8.1.1",
|
|
||||||
"react-native-reanimated": "^3.5.4",
|
|
||||||
"react-native-safe-area-context": "^4.7.4",
|
|
||||||
"react-native-screens": "^3.25.0",
|
|
||||||
"react-native-sqlite-storage": "^6.0.1",
|
|
||||||
"react-native-svg": "^13.14.0",
|
|
||||||
"react-native-vector-icons": "^10.0.2",
|
|
||||||
"react-native-vision-camera": "^3.3.5",
|
|
||||||
"react-native-voice": "^0.3.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/core": "^7.20.0",
|
|
||||||
"@babel/preset-env": "^7.20.0",
|
|
||||||
"@babel/runtime": "^7.20.0",
|
|
||||||
"@react-native/eslint-config": "^0.72.2",
|
|
||||||
"@react-native/metro-config": "^0.72.11",
|
|
||||||
"@tsconfig/react-native": "^3.0.0",
|
|
||||||
"@types/react": "^18.0.24",
|
|
||||||
"@types/react-test-renderer": "^18.0.0",
|
|
||||||
"babel-jest": "^29.2.1",
|
|
||||||
"eslint": "^8.19.0",
|
|
||||||
"jest": "^29.2.1",
|
|
||||||
"metro-react-native-babel-preset": "0.76.8",
|
|
||||||
"prettier": "^2.4.1",
|
|
||||||
"react-test-renderer": "18.2.0",
|
|
||||||
"typescript": "4.8.4"
|
|
||||||
},
|
|
||||||
"jest": {
|
|
||||||
"preset": "react-native"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
NavigationContainer,
|
|
||||||
DefaultTheme as NavigationDefaultTheme,
|
|
||||||
DarkTheme as NavigationDarkTheme,
|
|
||||||
} from '@react-navigation/native';
|
|
||||||
import {
|
|
||||||
Provider as PaperProvider,
|
|
||||||
DefaultTheme as PaperDefaultTheme,
|
|
||||||
MD3DarkTheme as PaperDarkTheme,
|
|
||||||
} from 'react-native-paper';
|
|
||||||
import { StatusBar } from 'react-native';
|
|
||||||
import { AuthProvider } from './services/AuthContext';
|
|
||||||
import { OfflineProvider } from './services/OfflineContext';
|
|
||||||
import { NotificationProvider } from './services/NotificationContext';
|
|
||||||
import { CameraProvider } from './services/CameraContext';
|
|
||||||
import { VoiceProvider } from './services/VoiceContext';
|
|
||||||
import { ServerConfigProvider } from './services/ServerConfigContext';
|
|
||||||
import { RealtimeSyncProvider } from './services/RealtimeSyncContext';
|
|
||||||
import AppNavigator from './navigation/AppNavigator';
|
|
||||||
import { loadTheme } from './utils/storage';
|
|
||||||
|
|
||||||
const CombinedDefaultTheme = {
|
|
||||||
...NavigationDefaultTheme,
|
|
||||||
...PaperDefaultTheme,
|
|
||||||
colors: {
|
|
||||||
...NavigationDefaultTheme.colors,
|
|
||||||
...PaperDefaultTheme.colors,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const CombinedDarkTheme = {
|
|
||||||
...NavigationDarkTheme,
|
|
||||||
...PaperDarkTheme,
|
|
||||||
colors: {
|
|
||||||
...NavigationDarkTheme.colors,
|
|
||||||
...PaperDarkTheme.colors,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const App: React.FC = () => {
|
|
||||||
const [isDarkTheme, setIsDarkTheme] = useState(false);
|
|
||||||
const [isThemeLoaded, setIsThemeLoaded] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const initializeTheme = async () => {
|
|
||||||
try {
|
|
||||||
const savedTheme = await loadTheme();
|
|
||||||
setIsDarkTheme(savedTheme === 'dark');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading theme:', error);
|
|
||||||
} finally {
|
|
||||||
setIsThemeLoaded(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initializeTheme();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const theme = isDarkTheme ? CombinedDarkTheme : CombinedDefaultTheme;
|
|
||||||
|
|
||||||
if (!isThemeLoaded) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PaperProvider theme={theme}>
|
|
||||||
<NavigationContainer theme={theme}>
|
|
||||||
<StatusBar
|
|
||||||
barStyle={isDarkTheme ? 'light-content' : 'dark-content'}
|
|
||||||
backgroundColor={theme.colors.background}
|
|
||||||
/>
|
|
||||||
<ServerConfigProvider>
|
|
||||||
<RealtimeSyncProvider>
|
|
||||||
<AuthProvider>
|
|
||||||
<NotificationProvider>
|
|
||||||
<CameraProvider>
|
|
||||||
<VoiceProvider>
|
|
||||||
<OfflineProvider>
|
|
||||||
<AppNavigator />
|
|
||||||
</OfflineProvider>
|
|
||||||
</VoiceProvider>
|
|
||||||
</CameraProvider>
|
|
||||||
</NotificationProvider>
|
|
||||||
</AuthProvider>
|
|
||||||
</RealtimeSyncProvider>
|
|
||||||
</ServerConfigProvider>
|
|
||||||
</NavigationContainer>
|
|
||||||
</PaperProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
|
||||||
import { useAuth } from '../services/AuthContext';
|
|
||||||
import { useServerConfig } from '../services/ServerConfigContext';
|
|
||||||
import AuthNavigator from './AuthNavigator';
|
|
||||||
import TabNavigator from './TabNavigator';
|
|
||||||
import LoadingScreen from '../screens/LoadingScreen';
|
|
||||||
import ServerSetupScreen from '../screens/ServerSetupScreen';
|
|
||||||
|
|
||||||
export type RootStackParamList = {
|
|
||||||
Auth: undefined;
|
|
||||||
Main: undefined;
|
|
||||||
Loading: undefined;
|
|
||||||
ServerSetup: undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
|
||||||
|
|
||||||
const AppNavigator: React.FC = () => {
|
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
|
||||||
const { isConfigured, isLoading: configLoading } = useServerConfig();
|
|
||||||
|
|
||||||
if (isLoading || configLoading) {
|
|
||||||
return <LoadingScreen />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
|
||||||
{!isConfigured ? (
|
|
||||||
<Stack.Screen name="ServerSetup" component={ServerSetupScreen} />
|
|
||||||
) : isAuthenticated ? (
|
|
||||||
<Stack.Screen name="Main" component={TabNavigator} />
|
|
||||||
) : (
|
|
||||||
<Stack.Screen name="Auth" component={AuthNavigator} />
|
|
||||||
)}
|
|
||||||
</Stack.Navigator>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AppNavigator;
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
|
||||||
import LoginScreen from '../screens/auth/LoginScreen';
|
|
||||||
import RegisterScreen from '../screens/auth/RegisterScreen';
|
|
||||||
|
|
||||||
export type AuthStackParamList = {
|
|
||||||
Login: undefined;
|
|
||||||
Register: undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<AuthStackParamList>();
|
|
||||||
|
|
||||||
const AuthNavigator: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<Stack.Navigator
|
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
|
||||||
gestureEnabled: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack.Screen name="Login" component={LoginScreen} />
|
|
||||||
<Stack.Screen name="Register" component={RegisterScreen} />
|
|
||||||
</Stack.Navigator>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AuthNavigator;
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
|
||||||
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
|
||||||
import { useOffline } from '../services/OfflineContext';
|
|
||||||
import { useTheme } from 'react-native-paper';
|
|
||||||
|
|
||||||
import DashboardScreen from '../screens/DashboardScreen';
|
|
||||||
import BookmarksScreen from '../screens/BookmarksScreen';
|
|
||||||
import TasksScreen from '../screens/TasksScreen';
|
|
||||||
import NotesScreen from '../screens/NotesScreen';
|
|
||||||
import TimeTrackingScreen from '../screens/TimeTrackingScreen';
|
|
||||||
import SearchScreen from '../screens/SearchScreen';
|
|
||||||
import SettingsScreen from '../screens/SettingsScreen';
|
|
||||||
import AIAssistantScreen from '../screens/AIAssistantScreen';
|
|
||||||
|
|
||||||
export type MainTabParamList = {
|
|
||||||
Dashboard: undefined;
|
|
||||||
Bookmarks: undefined;
|
|
||||||
Tasks: undefined;
|
|
||||||
Notes: undefined;
|
|
||||||
TimeTracking: undefined;
|
|
||||||
Search: undefined;
|
|
||||||
AIAssistant: undefined;
|
|
||||||
Settings: undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Tab = createBottomTabNavigator<MainTabParamList>();
|
|
||||||
|
|
||||||
const TabNavigator: React.FC = () => {
|
|
||||||
const { pendingChanges } = useOffline();
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tab.Navigator
|
|
||||||
screenOptions={({ route }) => ({
|
|
||||||
tabBarIcon: ({ color, size }) => {
|
|
||||||
let iconName: string;
|
|
||||||
|
|
||||||
switch (route.name) {
|
|
||||||
case 'Dashboard':
|
|
||||||
iconName = 'view-dashboard';
|
|
||||||
break;
|
|
||||||
case 'Bookmarks':
|
|
||||||
iconName = 'bookmark';
|
|
||||||
break;
|
|
||||||
case 'Tasks':
|
|
||||||
iconName = 'check-circle';
|
|
||||||
break;
|
|
||||||
case 'Notes':
|
|
||||||
iconName = 'note-text';
|
|
||||||
break;
|
|
||||||
case 'TimeTracking':
|
|
||||||
iconName = 'timer';
|
|
||||||
break;
|
|
||||||
case 'Search':
|
|
||||||
iconName = 'magnify';
|
|
||||||
break;
|
|
||||||
case 'AIAssistant':
|
|
||||||
iconName = 'robot';
|
|
||||||
break;
|
|
||||||
case 'Settings':
|
|
||||||
iconName = 'cog';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
iconName = 'help-circle';
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Icon name={iconName} size={size} color={color} />;
|
|
||||||
},
|
|
||||||
tabBarActiveTintColor: theme.colors.primary,
|
|
||||||
tabBarInactiveTintColor: 'gray',
|
|
||||||
tabBarStyle: {
|
|
||||||
backgroundColor: theme.colors.surface,
|
|
||||||
borderTopColor: theme.colors.outline,
|
|
||||||
},
|
|
||||||
headerStyle: {
|
|
||||||
backgroundColor: theme.colors.surface,
|
|
||||||
},
|
|
||||||
headerTintColor: theme.colors.onSurface,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Dashboard"
|
|
||||||
component={DashboardScreen}
|
|
||||||
options={{
|
|
||||||
title: 'Dashboard',
|
|
||||||
tabBarBadge: pendingChanges > 0 ? pendingChanges : undefined,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Bookmarks"
|
|
||||||
component={BookmarksScreen}
|
|
||||||
options={{ title: 'Bookmarks' }}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Tasks"
|
|
||||||
component={TasksScreen}
|
|
||||||
options={{ title: 'Tasks' }}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Notes"
|
|
||||||
component={NotesScreen}
|
|
||||||
options={{ title: 'Notes' }}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="TimeTracking"
|
|
||||||
component={TimeTrackingScreen}
|
|
||||||
options={{ title: 'Time' }}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Search"
|
|
||||||
component={SearchScreen}
|
|
||||||
options={{ title: 'Search' }}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="AIAssistant"
|
|
||||||
component={AIAssistantScreen}
|
|
||||||
options={{ title: 'AI' }}
|
|
||||||
/>
|
|
||||||
<Tab.Screen
|
|
||||||
name="Settings"
|
|
||||||
component={SettingsScreen}
|
|
||||||
options={{ title: 'Settings' }}
|
|
||||||
/>
|
|
||||||
</Tab.Navigator>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TabNavigator;
|
|
||||||
@@ -1,400 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
StyleSheet,
|
|
||||||
ScrollView,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
} from 'react-native';
|
|
||||||
import {
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
Paragraph,
|
|
||||||
TextInput,
|
|
||||||
Button,
|
|
||||||
Avatar,
|
|
||||||
Chip,
|
|
||||||
} from 'react-native-paper';
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import { useRealtimeUpdates } from '../services/RealtimeSyncContext';
|
|
||||||
import { useServerConfig } from '../services/ServerConfigContext';
|
|
||||||
|
|
||||||
interface Message {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
sender: 'user' | 'ai';
|
|
||||||
timestamp: Date;
|
|
||||||
type?: 'text' | 'recommendation' | 'analysis';
|
|
||||||
}
|
|
||||||
|
|
||||||
const AIAssistantScreen: React.FC = () => {
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [inputText, setInputText] = useState('');
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const { config } = useServerConfig();
|
|
||||||
|
|
||||||
const [suggestions] = useState([
|
|
||||||
'Help me organize my tasks',
|
|
||||||
'Suggest bookmarks for learning React',
|
|
||||||
'Analyze my productivity patterns',
|
|
||||||
'Create a study plan',
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Initialize with welcome message
|
|
||||||
setMessages([
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
text: "Hello! I'm your AI assistant. I can help you organize tasks, suggest bookmarks, analyze your productivity, and much more. How can I assist you today?",
|
|
||||||
sender: 'ai',
|
|
||||||
timestamp: new Date(),
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Listen for real-time AI updates
|
|
||||||
useRealtimeUpdates((data) => {
|
|
||||||
if (data.type === 'ai_response') {
|
|
||||||
const newMessage: Message = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
text: data.response,
|
|
||||||
sender: 'ai',
|
|
||||||
timestamp: new Date(),
|
|
||||||
type: data.responseType,
|
|
||||||
};
|
|
||||||
setMessages(prev => [...prev, newMessage]);
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSendMessage = async () => {
|
|
||||||
if (!inputText.trim()) return;
|
|
||||||
|
|
||||||
const userMessage: Message = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
text: inputText,
|
|
||||||
sender: 'user',
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
setMessages(prev => [...prev, userMessage]);
|
|
||||||
setInputText('');
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Call LongCat AI API
|
|
||||||
const response = await fetch(`${config?.baseUrl}/api/ai/chat`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${await getAuthToken()}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: inputText,
|
|
||||||
context: 'trackeep_assistant',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
const aiResponse: Message = {
|
|
||||||
id: (Date.now() + 1).toString(),
|
|
||||||
text: data.response,
|
|
||||||
sender: 'ai',
|
|
||||||
timestamp: new Date(),
|
|
||||||
type: data.type || 'text',
|
|
||||||
};
|
|
||||||
setMessages(prev => [...prev, aiResponse]);
|
|
||||||
} else {
|
|
||||||
// Fallback to mock response
|
|
||||||
const mockResponse: Message = {
|
|
||||||
id: (Date.now() + 1).toString(),
|
|
||||||
text: generateMockResponse(inputText),
|
|
||||||
sender: 'ai',
|
|
||||||
timestamp: new Date(),
|
|
||||||
type: 'text',
|
|
||||||
};
|
|
||||||
setMessages(prev => [...prev, mockResponse]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error calling AI API:', error);
|
|
||||||
// Fallback to mock response
|
|
||||||
const mockResponse: Message = {
|
|
||||||
id: (Date.now() + 1).toString(),
|
|
||||||
text: generateMockResponse(inputText),
|
|
||||||
sender: 'ai',
|
|
||||||
timestamp: new Date(),
|
|
||||||
type: 'text',
|
|
||||||
};
|
|
||||||
setMessages(prev => [...prev, mockResponse]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAuthToken = async (): Promise<string | null> => {
|
|
||||||
try {
|
|
||||||
const authData = await AsyncStorage.getItem('trackeep_auth_token');
|
|
||||||
return authData;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting auth token:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateMockResponse = (userInput: string): string => {
|
|
||||||
const input = userInput.toLowerCase();
|
|
||||||
|
|
||||||
if (input.includes('task') || input.includes('organize')) {
|
|
||||||
return "I can help you organize your tasks! Based on your current tasks, I suggest prioritizing the high-priority items first. Would you like me to create a schedule for you?";
|
|
||||||
} else if (input.includes('bookmark') || input.includes('learn')) {
|
|
||||||
return "Great! I can suggest relevant bookmarks for your learning goals. I see you're interested in React - here are some top resources I recommend...";
|
|
||||||
} else if (input.includes('productivity') || input.includes('analyze')) {
|
|
||||||
return "Looking at your activity patterns, you're most productive in the morning. I suggest scheduling important tasks between 9-11 AM for better results.";
|
|
||||||
} else if (input.includes('study') || input.includes('plan')) {
|
|
||||||
return "I can create a personalized study plan for you! Based on your current notes and bookmarks, here's a structured learning path...";
|
|
||||||
} else {
|
|
||||||
return "I understand you need help with that. Let me analyze your current data and provide you with personalized recommendations.";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSuggestionPress = (suggestion: string) => {
|
|
||||||
setInputText(suggestion);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (date: Date): string => {
|
|
||||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderMessage = (message: Message) => (
|
|
||||||
<View key={message.id} style={[
|
|
||||||
styles.messageContainer,
|
|
||||||
message.sender === 'user' ? styles.userMessage : styles.aiMessage,
|
|
||||||
]}>
|
|
||||||
{message.sender === 'ai' && (
|
|
||||||
<Avatar.Text
|
|
||||||
size={32}
|
|
||||||
label="AI"
|
|
||||||
style={styles.avatar}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<View style={[
|
|
||||||
styles.messageBubble,
|
|
||||||
message.sender === 'user' ? styles.userBubble : styles.aiBubble,
|
|
||||||
]}>
|
|
||||||
<Text style={[
|
|
||||||
styles.messageText,
|
|
||||||
message.sender === 'user' ? styles.userText : styles.aiText,
|
|
||||||
]}>
|
|
||||||
{message.text}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.timestamp}>
|
|
||||||
{formatTime(message.timestamp)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{message.sender === 'user' && (
|
|
||||||
<Avatar.Text
|
|
||||||
size={32}
|
|
||||||
label="U"
|
|
||||||
style={styles.avatar}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
||||||
style={styles.container}
|
|
||||||
>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<Title style={styles.title}>AI Assistant</Title>
|
|
||||||
<Paragraph style={styles.subtitle}>
|
|
||||||
Your personal productivity companion
|
|
||||||
</Paragraph>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
style={styles.messagesContainer}
|
|
||||||
contentContainerStyle={styles.messagesContent}
|
|
||||||
>
|
|
||||||
{messages.map(renderMessage)}
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<View style={[styles.messageContainer, styles.aiMessage]}>
|
|
||||||
<Avatar.Text
|
|
||||||
size={32}
|
|
||||||
label="AI"
|
|
||||||
style={styles.avatar}
|
|
||||||
/>
|
|
||||||
<View style={[styles.messageBubble, styles.aiBubble]}>
|
|
||||||
<Text style={styles.aiText}>Thinking...</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* Suggestions */}
|
|
||||||
{messages.length === 1 && (
|
|
||||||
<View style={styles.suggestionsContainer}>
|
|
||||||
<Text style={styles.suggestionsTitle}>Try asking:</Text>
|
|
||||||
<View style={styles.suggestionsList}>
|
|
||||||
{suggestions.map((suggestion, index) => (
|
|
||||||
<Chip
|
|
||||||
key={index}
|
|
||||||
onPress={() => handleSuggestionPress(suggestion)}
|
|
||||||
style={styles.suggestionChip}
|
|
||||||
textStyle={styles.suggestionText}
|
|
||||||
>
|
|
||||||
{suggestion}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Input Area */}
|
|
||||||
<View style={styles.inputContainer}>
|
|
||||||
<TextInput
|
|
||||||
value={inputText}
|
|
||||||
onChangeText={setInputText}
|
|
||||||
placeholder="Ask me anything..."
|
|
||||||
multiline
|
|
||||||
maxLength={500}
|
|
||||||
style={styles.textInput}
|
|
||||||
right={
|
|
||||||
<TextInput.Icon
|
|
||||||
icon="send"
|
|
||||||
onPress={handleSendMessage}
|
|
||||||
disabled={!inputText.trim() || isLoading}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
mode="contained"
|
|
||||||
onPress={handleSendMessage}
|
|
||||||
disabled={!inputText.trim() || isLoading}
|
|
||||||
loading={isLoading}
|
|
||||||
style={styles.sendButton}
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#e0e0e0',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#6200ee',
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
color: '#666',
|
|
||||||
marginTop: 4,
|
|
||||||
},
|
|
||||||
messagesContainer: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
messagesContent: {
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
messageContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
marginBottom: 16,
|
|
||||||
alignItems: 'flex-end',
|
|
||||||
},
|
|
||||||
userMessage: {
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
},
|
|
||||||
aiMessage: {
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
},
|
|
||||||
avatar: {
|
|
||||||
marginHorizontal: 8,
|
|
||||||
backgroundColor: '#6200ee',
|
|
||||||
},
|
|
||||||
messageBubble: {
|
|
||||||
maxWidth: '70%',
|
|
||||||
padding: 12,
|
|
||||||
borderRadius: 16,
|
|
||||||
minHeight: 40,
|
|
||||||
},
|
|
||||||
userBubble: {
|
|
||||||
backgroundColor: '#6200ee',
|
|
||||||
borderBottomRightRadius: 4,
|
|
||||||
},
|
|
||||||
aiBubble: {
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderBottomLeftRadius: 4,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: '#e0e0e0',
|
|
||||||
},
|
|
||||||
messageText: {
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 20,
|
|
||||||
},
|
|
||||||
userText: {
|
|
||||||
color: '#fff',
|
|
||||||
},
|
|
||||||
aiText: {
|
|
||||||
color: '#333',
|
|
||||||
},
|
|
||||||
timestamp: {
|
|
||||||
fontSize: 11,
|
|
||||||
color: '#999',
|
|
||||||
marginTop: 4,
|
|
||||||
alignSelf: 'flex-end',
|
|
||||||
},
|
|
||||||
suggestionsContainer: {
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: '#e0e0e0',
|
|
||||||
},
|
|
||||||
suggestionsTitle: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#666',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
suggestionsList: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
suggestionChip: {
|
|
||||||
backgroundColor: '#f0f0f0',
|
|
||||||
},
|
|
||||||
suggestionText: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#333',
|
|
||||||
},
|
|
||||||
inputContainer: {
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: '#e0e0e0',
|
|
||||||
},
|
|
||||||
textInput: {
|
|
||||||
marginBottom: 8,
|
|
||||||
backgroundColor: '#f8f8f8',
|
|
||||||
},
|
|
||||||
sendButton: {
|
|
||||||
backgroundColor: '#6200ee',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default AIAssistantScreen;
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { View, StyleSheet, FlatList } from 'react-native';
|
|
||||||
import { Text, Card, Title, Paragraph, FAB, Searchbar } from 'react-native-paper';
|
|
||||||
|
|
||||||
const BookmarksScreen: React.FC = () => {
|
|
||||||
const [searchQuery, setSearchQuery] = React.useState('');
|
|
||||||
const [bookmarks] = React.useState([
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
title: 'React Native Documentation',
|
|
||||||
url: 'https://reactnative.dev',
|
|
||||||
description: 'Official React Native documentation',
|
|
||||||
tags: ['react', 'mobile', 'documentation'],
|
|
||||||
isFavorite: true,
|
|
||||||
createdAt: new Date(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
title: 'TypeScript Handbook',
|
|
||||||
url: 'https://www.typescriptlang.org/docs',
|
|
||||||
description: 'Learn TypeScript from the official handbook',
|
|
||||||
tags: ['typescript', 'programming', 'tutorial'],
|
|
||||||
isFavorite: false,
|
|
||||||
createdAt: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const onChangeSearch = (query: string) => setSearchQuery(query);
|
|
||||||
|
|
||||||
const renderBookmark = ({ item }: any) => (
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title numberOfLines={1}>{item.title}</Title>
|
|
||||||
<Paragraph numberOfLines={2}>{item.description}</Paragraph>
|
|
||||||
<Text style={styles.url}>{item.url}</Text>
|
|
||||||
<View style={styles.tagsContainer}>
|
|
||||||
{item.tags.map((tag: string, index: number) => (
|
|
||||||
<Text key={index} style={styles.tag}>
|
|
||||||
#{tag}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Searchbar
|
|
||||||
placeholder="Search bookmarks..."
|
|
||||||
onChangeText={onChangeSearch}
|
|
||||||
value={searchQuery}
|
|
||||||
style={styles.searchBar}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FlatList
|
|
||||||
data={bookmarks}
|
|
||||||
renderItem={renderBookmark}
|
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
contentContainerStyle={styles.list}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FAB
|
|
||||||
icon="bookmark-plus"
|
|
||||||
style={styles.fab}
|
|
||||||
onPress={() => console.log('Add bookmark')}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
},
|
|
||||||
searchBar: {
|
|
||||||
margin: 16,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
list: {
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingBottom: 80,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
marginBottom: 12,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
url: {
|
|
||||||
color: '#6200ee',
|
|
||||||
fontSize: 12,
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
tagsContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
tag: {
|
|
||||||
backgroundColor: '#e3f2fd',
|
|
||||||
color: '#1976d2',
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
paddingVertical: 4,
|
|
||||||
borderRadius: 12,
|
|
||||||
fontSize: 10,
|
|
||||||
marginRight: 4,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
fab: {
|
|
||||||
position: 'absolute',
|
|
||||||
margin: 16,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: '#6200ee',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default BookmarksScreen;
|
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { View, StyleSheet, ScrollView, RefreshControl, Dimensions } from 'react-native';
|
|
||||||
import { Text, Card, Title, Paragraph, Button, FAB, Avatar, Chip, ProgressBar } from 'react-native-paper';
|
|
||||||
import { useAuth } from '../services/AuthContext';
|
|
||||||
import { useOffline } from '../services/OfflineContext';
|
|
||||||
import { useRealtimeSync, useRealtimeUpdates } from '../services/RealtimeSyncContext';
|
|
||||||
import { bookmarksAPI, tasksAPI, notesAPI } from '../services/api';
|
|
||||||
|
|
||||||
interface QuickStats {
|
|
||||||
totalBookmarks: number;
|
|
||||||
totalTasks: number;
|
|
||||||
totalNotes: number;
|
|
||||||
completedTasks: number;
|
|
||||||
recentActivity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecentActivity {
|
|
||||||
id: string;
|
|
||||||
type: 'bookmark' | 'task' | 'note';
|
|
||||||
action: string;
|
|
||||||
title: string;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
|
||||||
|
|
||||||
const DashboardScreen: React.FC = () => {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const { isOnline, pendingChanges, syncNow } = useOffline();
|
|
||||||
const { isSyncing, lastSyncTime } = useRealtimeSync();
|
|
||||||
|
|
||||||
const [stats, setStats] = useState<QuickStats>({
|
|
||||||
totalBookmarks: 0,
|
|
||||||
totalTasks: 0,
|
|
||||||
totalNotes: 0,
|
|
||||||
completedTasks: 0,
|
|
||||||
recentActivity: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [recentActivity, setRecentActivity] = useState<RecentActivity[]>([]);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadDashboardData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Listen for real-time updates
|
|
||||||
useRealtimeUpdates((data) => {
|
|
||||||
console.log('Dashboard received real-time update:', data);
|
|
||||||
loadDashboardData();
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadDashboardData = async () => {
|
|
||||||
try {
|
|
||||||
const [bookmarksRes, tasksRes, notesRes] = await Promise.all([
|
|
||||||
bookmarksAPI.getBookmarks(),
|
|
||||||
tasksAPI.getTasks(),
|
|
||||||
notesAPI.getNotes(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (bookmarksRes.success && tasksRes.success && notesRes.success) {
|
|
||||||
const bookmarks = bookmarksRes.data || [];
|
|
||||||
const tasks = tasksRes.data || [];
|
|
||||||
const notes = notesRes.data || [];
|
|
||||||
|
|
||||||
const completedTasks = tasks.filter(task => (task as any).completed).length;
|
|
||||||
|
|
||||||
setStats({
|
|
||||||
totalBookmarks: bookmarks.length,
|
|
||||||
totalTasks: tasks.length,
|
|
||||||
totalNotes: notes.length,
|
|
||||||
completedTasks,
|
|
||||||
recentActivity: 5, // Mock recent activity count
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate mock recent activity
|
|
||||||
const activity: RecentActivity[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
type: 'bookmark',
|
|
||||||
action: 'Added',
|
|
||||||
title: bookmarks[0]?.title || 'New bookmark',
|
|
||||||
timestamp: '2 hours ago',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
type: 'task',
|
|
||||||
action: 'Completed',
|
|
||||||
title: tasks[0]?.title || 'New task',
|
|
||||||
timestamp: '3 hours ago',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
type: 'note',
|
|
||||||
action: 'Created',
|
|
||||||
title: notes[0]?.title || 'New note',
|
|
||||||
timestamp: '5 hours ago',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
setRecentActivity(activity);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading dashboard data:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRefresh = async () => {
|
|
||||||
setRefreshing(true);
|
|
||||||
await loadDashboardData();
|
|
||||||
if (isOnline && pendingChanges > 0) {
|
|
||||||
await syncNow();
|
|
||||||
}
|
|
||||||
setRefreshing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTaskCompletionPercentage = () => {
|
|
||||||
if (stats.totalTasks === 0) return 0;
|
|
||||||
return Math.round((stats.completedTasks / stats.totalTasks) * 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatLastSync = () => {
|
|
||||||
if (!lastSyncTime) return 'Never';
|
|
||||||
const now = Date.now();
|
|
||||||
const diff = now - lastSyncTime;
|
|
||||||
const minutes = Math.floor(diff / 60000);
|
|
||||||
|
|
||||||
if (minutes < 1) return 'Just now';
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
return `${Math.floor(hours / 24)}d ago`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<ScrollView
|
|
||||||
style={styles.scrollView}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Header Section */}
|
|
||||||
<View style={styles.header}>
|
|
||||||
<View style={styles.userSection}>
|
|
||||||
<Avatar.Text
|
|
||||||
size={60}
|
|
||||||
label={user?.name?.charAt(0).toUpperCase() || 'U'}
|
|
||||||
style={styles.avatar}
|
|
||||||
/>
|
|
||||||
<View style={styles.userInfo}>
|
|
||||||
<Title style={styles.welcomeText}>
|
|
||||||
Welcome back, {user?.name || 'User'}!
|
|
||||||
</Title>
|
|
||||||
<Paragraph style={styles.subtitle}>
|
|
||||||
{isOnline ? '🟢 Connected' : '🔴 Offline'} •
|
|
||||||
{isSyncing ? ' Syncing...' : ` Last sync: ${formatLastSync()}`}
|
|
||||||
</Paragraph>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Quick Stats Cards */}
|
|
||||||
<View style={styles.statsGrid}>
|
|
||||||
<Card style={[styles.statCard, { backgroundColor: '#e3f2fd' }]}>
|
|
||||||
<Card.Content style={styles.statContent}>
|
|
||||||
<Text style={[styles.statNumber, { color: '#1976d2' }]}>
|
|
||||||
{stats.totalBookmarks}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.statLabel}>Bookmarks</Text>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card style={[styles.statCard, { backgroundColor: '#e8f5e8' }]}>
|
|
||||||
<Card.Content style={styles.statContent}>
|
|
||||||
<Text style={[styles.statNumber, { color: '#388e3c' }]}>
|
|
||||||
{stats.totalTasks}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.statLabel}>Tasks</Text>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card style={[styles.statCard, { backgroundColor: '#fff3e0' }]}>
|
|
||||||
<Card.Content style={styles.statContent}>
|
|
||||||
<Text style={[styles.statNumber, { color: '#f57c00' }]}>
|
|
||||||
{stats.totalNotes}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.statLabel}>Notes</Text>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Task Progress */}
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title style={styles.cardTitle}>Task Progress</Title>
|
|
||||||
<View style={styles.progressContainer}>
|
|
||||||
<Text style={styles.progressText}>
|
|
||||||
{stats.completedTasks} of {stats.totalTasks} tasks completed
|
|
||||||
</Text>
|
|
||||||
<ProgressBar
|
|
||||||
progress={getTaskCompletionPercentage() / 100}
|
|
||||||
color="#4caf50"
|
|
||||||
style={styles.progressBar}
|
|
||||||
/>
|
|
||||||
<Text style={styles.progressPercentage}>
|
|
||||||
{getTaskCompletionPercentage()}%
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Recent Activity */}
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title style={styles.cardTitle}>Recent Activity</Title>
|
|
||||||
{recentActivity.length > 0 ? (
|
|
||||||
recentActivity.map((activity) => (
|
|
||||||
<View key={activity.id} style={styles.activityItem}>
|
|
||||||
<View style={styles.activityIcon}>
|
|
||||||
<Text style={styles.activityEmoji}>
|
|
||||||
{activity.type === 'bookmark' ? '🔖' :
|
|
||||||
activity.type === 'task' ? '✅' : '📝'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.activityContent}>
|
|
||||||
<Text style={styles.activityTitle}>
|
|
||||||
{activity.action} {activity.title}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.activityTime}>
|
|
||||||
{activity.timestamp}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Paragraph style={styles.emptyText}>No recent activity</Paragraph>
|
|
||||||
)}
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Sync Status */}
|
|
||||||
{!isOnline && pendingChanges > 0 && (
|
|
||||||
<Card style={[styles.card, styles.offlineCard]}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title style={styles.cardTitle}>Offline Mode</Title>
|
|
||||||
<Paragraph>
|
|
||||||
You have {pendingChanges} changes pending sync
|
|
||||||
</Paragraph>
|
|
||||||
<Button
|
|
||||||
mode="outlined"
|
|
||||||
onPress={syncNow}
|
|
||||||
style={styles.syncButton}
|
|
||||||
disabled={!isOnline || isSyncing}
|
|
||||||
loading={isSyncing}
|
|
||||||
>
|
|
||||||
{isSyncing ? 'Syncing...' : 'Sync Now'}
|
|
||||||
</Button>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title style={styles.cardTitle}>Quick Actions</Title>
|
|
||||||
<View style={styles.quickActions}>
|
|
||||||
<Chip
|
|
||||||
icon="bookmark-plus"
|
|
||||||
onPress={() => console.log('Add bookmark')}
|
|
||||||
style={styles.actionChip}
|
|
||||||
>
|
|
||||||
Add Bookmark
|
|
||||||
</Chip>
|
|
||||||
<Chip
|
|
||||||
icon="plus"
|
|
||||||
onPress={() => console.log('Add task')}
|
|
||||||
style={styles.actionChip}
|
|
||||||
>
|
|
||||||
Add Task
|
|
||||||
</Chip>
|
|
||||||
<Chip
|
|
||||||
icon="note-plus"
|
|
||||||
onPress={() => console.log('Add note')}
|
|
||||||
style={styles.actionChip}
|
|
||||||
>
|
|
||||||
Add Note
|
|
||||||
</Chip>
|
|
||||||
</View>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<FAB
|
|
||||||
icon="plus"
|
|
||||||
style={styles.fab}
|
|
||||||
onPress={() => console.log('Add new item')}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
},
|
|
||||||
scrollView: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
userSection: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
avatar: {
|
|
||||||
marginRight: 16,
|
|
||||||
backgroundColor: '#6200ee',
|
|
||||||
},
|
|
||||||
userInfo: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
welcomeText: {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
color: '#666',
|
|
||||||
marginTop: 4,
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
statsGrid: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
statCard: {
|
|
||||||
width: (width - 48) / 3,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
statContent: {
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingVertical: 16,
|
|
||||||
},
|
|
||||||
statNumber: {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
statLabel: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#666',
|
|
||||||
marginTop: 4,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
marginBottom: 16,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
cardTitle: {
|
|
||||||
fontSize: 18,
|
|
||||||
marginBottom: 12,
|
|
||||||
color: '#333',
|
|
||||||
},
|
|
||||||
progressContainer: {
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
progressText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#666',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
progressBar: {
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
progressPercentage: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#4caf50',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
activityItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#f0f0f0',
|
|
||||||
},
|
|
||||||
activityIcon: {
|
|
||||||
marginRight: 12,
|
|
||||||
},
|
|
||||||
activityEmoji: {
|
|
||||||
fontSize: 20,
|
|
||||||
},
|
|
||||||
activityContent: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
activityTitle: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: '#333',
|
|
||||||
},
|
|
||||||
activityTime: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#666',
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
emptyText: {
|
|
||||||
textAlign: 'center',
|
|
||||||
color: '#666',
|
|
||||||
fontStyle: 'italic',
|
|
||||||
},
|
|
||||||
offlineCard: {
|
|
||||||
backgroundColor: '#fff3cd',
|
|
||||||
borderColor: '#ffeaa7',
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
syncButton: {
|
|
||||||
marginTop: 12,
|
|
||||||
},
|
|
||||||
quickActions: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
actionChip: {
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
fab: {
|
|
||||||
position: 'absolute',
|
|
||||||
margin: 16,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: '#6200ee',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default DashboardScreen;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { View, StyleSheet } from 'react-native';
|
|
||||||
import { ActivityIndicator, Text } from 'react-native-paper';
|
|
||||||
|
|
||||||
const LoadingScreen: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<ActivityIndicator size="large" />
|
|
||||||
<Text style={styles.text}>Loading Trackeep...</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
marginTop: 16,
|
|
||||||
fontSize: 16,
|
|
||||||
color: '#666',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default LoadingScreen;
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { View, StyleSheet, FlatList } from 'react-native';
|
|
||||||
import { Text, Card, Title, Paragraph, FAB } from 'react-native-paper';
|
|
||||||
|
|
||||||
const NotesScreen: React.FC = () => {
|
|
||||||
const [notes] = React.useState([
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
title: 'Mobile App Architecture',
|
|
||||||
content: 'React Native with TypeScript, navigation, offline support...',
|
|
||||||
tags: ['architecture', 'mobile', 'react-native'],
|
|
||||||
createdAt: new Date(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
title: 'Meeting Notes - Product Review',
|
|
||||||
content: 'Discussed new features, timeline, and user feedback...',
|
|
||||||
tags: ['meeting', 'product', 'review'],
|
|
||||||
createdAt: new Date(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const renderNote = ({ item }: any) => (
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title numberOfLines={1}>{item.title}</Title>
|
|
||||||
<Paragraph numberOfLines={3}>{item.content}</Paragraph>
|
|
||||||
<View style={styles.tagsContainer}>
|
|
||||||
{item.tags.map((tag: string, index: number) => (
|
|
||||||
<Text key={index} style={styles.tag}>
|
|
||||||
#{tag}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
<Text style={styles.date}>
|
|
||||||
{item.createdAt.toLocaleDateString()}
|
|
||||||
</Text>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<FlatList
|
|
||||||
data={notes}
|
|
||||||
renderItem={renderNote}
|
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
contentContainerStyle={styles.list}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FAB
|
|
||||||
icon="plus"
|
|
||||||
style={styles.fab}
|
|
||||||
onPress={() => console.log('Add note')}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
},
|
|
||||||
list: {
|
|
||||||
padding: 16,
|
|
||||||
paddingBottom: 80,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
marginBottom: 12,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
tagsContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
tag: {
|
|
||||||
backgroundColor: '#e8f5e8',
|
|
||||||
color: '#2e7d32',
|
|
||||||
paddingHorizontal: 8,
|
|
||||||
paddingVertical: 4,
|
|
||||||
borderRadius: 12,
|
|
||||||
fontSize: 10,
|
|
||||||
marginRight: 4,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
date: {
|
|
||||||
fontSize: 10,
|
|
||||||
color: '#666',
|
|
||||||
marginTop: 8,
|
|
||||||
textAlign: 'right',
|
|
||||||
},
|
|
||||||
fab: {
|
|
||||||
position: 'absolute',
|
|
||||||
margin: 16,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: '#6200ee',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default NotesScreen;
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { View, StyleSheet, FlatList } from 'react-native';
|
|
||||||
import { Text, Card, Title, Paragraph, Searchbar, Chip } from 'react-native-paper';
|
|
||||||
|
|
||||||
const SearchScreen: React.FC = () => {
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [selectedFilter, setSelectedFilter] = useState('all');
|
|
||||||
|
|
||||||
const filters = [
|
|
||||||
{ id: 'all', label: 'All' },
|
|
||||||
{ id: 'bookmarks', label: 'Bookmarks' },
|
|
||||||
{ id: 'tasks', label: 'Tasks' },
|
|
||||||
{ id: 'notes', label: 'Notes' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const searchResults = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
type: 'bookmark',
|
|
||||||
title: 'React Native Documentation',
|
|
||||||
description: 'Official React Native documentation and guides',
|
|
||||||
url: 'https://reactnative.dev',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
type: 'task',
|
|
||||||
title: 'Complete mobile app setup',
|
|
||||||
description: 'Finish React Native project structure and navigation',
|
|
||||||
status: 'in_progress',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
type: 'note',
|
|
||||||
title: 'Mobile App Architecture',
|
|
||||||
content: 'React Native with TypeScript, navigation patterns...',
|
|
||||||
tags: ['architecture', 'mobile'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const onChangeSearch = (query: string) => setSearchQuery(query);
|
|
||||||
|
|
||||||
const renderResult = ({ item }: any) => {
|
|
||||||
const getTypeIcon = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'bookmark': return '🔖';
|
|
||||||
case 'task': return '✅';
|
|
||||||
case 'note': return '📝';
|
|
||||||
default: return '📄';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTypeColor = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'bookmark': return '#1976d2';
|
|
||||||
case 'task': return '#f44336';
|
|
||||||
case 'note': return '#4caf50';
|
|
||||||
default: return '#666';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card style={styles.resultCard}>
|
|
||||||
<Card.Content>
|
|
||||||
<View style={styles.resultHeader}>
|
|
||||||
<Text style={styles.typeIcon}>{getTypeIcon(item.type)}</Text>
|
|
||||||
<Text style={[styles.typeLabel, { color: getTypeColor(item.type) }]}>
|
|
||||||
{item.type.charAt(0).toUpperCase() + item.type.slice(1)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Title numberOfLines={1} style={styles.resultTitle}>
|
|
||||||
{item.title}
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
<Paragraph numberOfLines={2} style={styles.resultDescription}>
|
|
||||||
{item.description || item.content}
|
|
||||||
</Paragraph>
|
|
||||||
|
|
||||||
{item.url && (
|
|
||||||
<Text style={styles.resultUrl} numberOfLines={1}>
|
|
||||||
{item.url}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{item.tags && (
|
|
||||||
<View style={styles.tagsContainer}>
|
|
||||||
{item.tags.map((tag: string, index: number) => (
|
|
||||||
<Chip key={index} style={styles.tag}>
|
|
||||||
{tag}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Searchbar
|
|
||||||
placeholder="Search everything..."
|
|
||||||
onChangeText={onChangeSearch}
|
|
||||||
value={searchQuery}
|
|
||||||
style={styles.searchBar}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<View style={styles.filtersContainer}>
|
|
||||||
{filters.map(filter => (
|
|
||||||
<Chip
|
|
||||||
key={filter.id}
|
|
||||||
selected={selectedFilter === filter.id}
|
|
||||||
onPress={() => setSelectedFilter(filter.id)}
|
|
||||||
style={styles.filterChip}
|
|
||||||
>
|
|
||||||
{filter.label}
|
|
||||||
</Chip>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<FlatList
|
|
||||||
data={searchResults}
|
|
||||||
renderItem={renderResult}
|
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
contentContainerStyle={styles.resultsList}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
ListEmptyComponent={
|
|
||||||
<View style={styles.emptyContainer}>
|
|
||||||
<Text style={styles.emptyText}>
|
|
||||||
{searchQuery ? 'No results found' : 'Start typing to search'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
},
|
|
||||||
searchBar: {
|
|
||||||
margin: 16,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
filtersContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingBottom: 8,
|
|
||||||
},
|
|
||||||
filterChip: {
|
|
||||||
marginRight: 8,
|
|
||||||
},
|
|
||||||
resultsList: {
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingBottom: 16,
|
|
||||||
},
|
|
||||||
resultCard: {
|
|
||||||
marginBottom: 12,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
resultHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
typeIcon: {
|
|
||||||
fontSize: 16,
|
|
||||||
marginRight: 8,
|
|
||||||
},
|
|
||||||
typeLabel: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
},
|
|
||||||
resultTitle: {
|
|
||||||
fontSize: 16,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
resultDescription: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#666',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
resultUrl: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#1976d2',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
tagsContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
},
|
|
||||||
tag: {
|
|
||||||
marginRight: 4,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
emptyContainer: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingVertical: 60,
|
|
||||||
},
|
|
||||||
emptyText: {
|
|
||||||
fontSize: 16,
|
|
||||||
color: '#666',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default SearchScreen;
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
StyleSheet,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
Alert,
|
|
||||||
} from 'react-native';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
Title,
|
|
||||||
Paragraph,
|
|
||||||
TextInput,
|
|
||||||
Button,
|
|
||||||
HelperText,
|
|
||||||
} from 'react-native-paper';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import { useServerConfig } from '../services/ServerConfigContext';
|
|
||||||
import { updateAPIBaseURL } from '../services/api';
|
|
||||||
|
|
||||||
interface ServerConfig {
|
|
||||||
baseUrl: string;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ServerSetupScreen: React.FC = () => {
|
|
||||||
const [config, setConfig] = useState<ServerConfig>({
|
|
||||||
baseUrl: '',
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
});
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [errors, setErrors] = useState<Partial<ServerConfig>>({});
|
|
||||||
|
|
||||||
const { setConfig: saveConfig } = useServerConfig();
|
|
||||||
|
|
||||||
const validateConfig = (): boolean => {
|
|
||||||
const newErrors: Partial<ServerConfig> = {};
|
|
||||||
|
|
||||||
if (!config.baseUrl.trim()) {
|
|
||||||
newErrors.baseUrl = 'Server URL is required';
|
|
||||||
} else if (!isValidUrl(config.baseUrl)) {
|
|
||||||
newErrors.baseUrl = 'Please enter a valid URL (e.g., https://your-server.com)';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config.username.trim()) {
|
|
||||||
newErrors.username = 'Username is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config.password.trim()) {
|
|
||||||
newErrors.password = 'Password is required';
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isValidUrl = (url: string): boolean => {
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`);
|
|
||||||
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const testConnection = async (): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
||||||
|
|
||||||
const response = await fetch(`${config.baseUrl}/api/health`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Connection test failed:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTestConnection = async () => {
|
|
||||||
if (!config.baseUrl.trim()) {
|
|
||||||
Alert.alert('Error', 'Please enter a server URL first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const isConnected = await testConnection();
|
|
||||||
if (isConnected) {
|
|
||||||
Alert.alert('Success', 'Connection to server successful!');
|
|
||||||
} else {
|
|
||||||
Alert.alert(
|
|
||||||
'Connection Failed',
|
|
||||||
'Could not connect to the server. Please check the URL and ensure the server is running.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
Alert.alert('Error', 'Failed to test connection. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSetup = async () => {
|
|
||||||
if (!validateConfig()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const isConnected = await testConnection();
|
|
||||||
if (!isConnected) {
|
|
||||||
Alert.alert(
|
|
||||||
'Connection Failed',
|
|
||||||
'Could not connect to the server. Please check the URL and try again.'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test authentication
|
|
||||||
const authResponse = await fetch(`${config.baseUrl}/api/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: config.username,
|
|
||||||
password: config.password,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (authResponse.ok) {
|
|
||||||
const authData = await authResponse.json();
|
|
||||||
if (authData.token) {
|
|
||||||
await saveConfig(config);
|
|
||||||
updateAPIBaseURL(`${config.baseUrl}/api`);
|
|
||||||
Alert.alert('Success', 'Server configuration completed successfully!');
|
|
||||||
// Navigation will be handled automatically by the AppNavigator
|
|
||||||
} else {
|
|
||||||
Alert.alert('Authentication Failed', 'Invalid username or password.');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Alert.alert('Authentication Failed', 'Invalid username or password.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
Alert.alert('Setup Failed', 'An error occurred during setup. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.container}>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
||||||
style={styles.keyboardAvoidingView}
|
|
||||||
>
|
|
||||||
<View style={styles.content}>
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title style={styles.title}>Welcome to Trackeep</Title>
|
|
||||||
<Paragraph style={styles.subtitle}>
|
|
||||||
Connect to your Trackeep server to get started
|
|
||||||
</Paragraph>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title style={styles.cardTitle}>Server Configuration</Title>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="Server URL"
|
|
||||||
value={config.baseUrl}
|
|
||||||
onChangeText={(text) => setConfig({ ...config, baseUrl: text })}
|
|
||||||
placeholder="https://your-server.com"
|
|
||||||
autoCapitalize="none"
|
|
||||||
keyboardType="url"
|
|
||||||
style={styles.input}
|
|
||||||
error={!!errors.baseUrl}
|
|
||||||
/>
|
|
||||||
<HelperText type="error" visible={!!errors.baseUrl}>
|
|
||||||
{errors.baseUrl}
|
|
||||||
</HelperText>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="Username"
|
|
||||||
value={config.username}
|
|
||||||
onChangeText={(text) => setConfig({ ...config, username: text })}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
style={styles.input}
|
|
||||||
error={!!errors.username}
|
|
||||||
/>
|
|
||||||
<HelperText type="error" visible={!!errors.username}>
|
|
||||||
{errors.username}
|
|
||||||
</HelperText>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="Password"
|
|
||||||
value={config.password}
|
|
||||||
onChangeText={(text) => setConfig({ ...config, password: text })}
|
|
||||||
secureTextEntry
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
style={styles.input}
|
|
||||||
error={!!errors.password}
|
|
||||||
/>
|
|
||||||
<HelperText type="error" visible={!!errors.password}>
|
|
||||||
{errors.password}
|
|
||||||
</HelperText>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
mode="outlined"
|
|
||||||
onPress={handleTestConnection}
|
|
||||||
disabled={isLoading || !config.baseUrl.trim()}
|
|
||||||
style={styles.testButton}
|
|
||||||
loading={isLoading}
|
|
||||||
>
|
|
||||||
Test Connection
|
|
||||||
</Button>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card style={styles.infoCard}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title style={styles.cardTitle}>Need Help?</Title>
|
|
||||||
<Paragraph style={styles.infoText}>
|
|
||||||
• Enter the full URL of your Trackeep server
|
|
||||||
</Paragraph>
|
|
||||||
<Paragraph style={styles.infoText}>
|
|
||||||
• Use your existing Trackeep account credentials
|
|
||||||
</Paragraph>
|
|
||||||
<Paragraph style={styles.infoText}>
|
|
||||||
• Make sure your server is accessible from this device
|
|
||||||
</Paragraph>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
mode="contained"
|
|
||||||
onPress={handleSetup}
|
|
||||||
disabled={isLoading}
|
|
||||||
loading={isLoading}
|
|
||||||
style={styles.setupButton}
|
|
||||||
contentStyle={styles.setupButtonContent}
|
|
||||||
>
|
|
||||||
Complete Setup
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
},
|
|
||||||
keyboardAvoidingView: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 16,
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
marginBottom: 16,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
infoCard: {
|
|
||||||
marginBottom: 24,
|
|
||||||
backgroundColor: '#e3f2fd',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
textAlign: 'center',
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#6200ee',
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
textAlign: 'center',
|
|
||||||
marginTop: 8,
|
|
||||||
color: '#666',
|
|
||||||
},
|
|
||||||
cardTitle: {
|
|
||||||
fontSize: 18,
|
|
||||||
marginBottom: 16,
|
|
||||||
color: '#333',
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
testButton: {
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
infoText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: '#666',
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
setupButton: {
|
|
||||||
backgroundColor: '#6200ee',
|
|
||||||
},
|
|
||||||
setupButtonContent: {
|
|
||||||
paddingVertical: 8,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ServerSetupScreen;
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { View, StyleSheet, ScrollView, Alert } from 'react-native';
|
|
||||||
import { List, Switch, Text, Card, Title, Button } from 'react-native-paper';
|
|
||||||
import { useAuth } from '../services/AuthContext';
|
|
||||||
import { useOffline } from '../services/OfflineContext';
|
|
||||||
import { useNotifications } from '../services/NotificationContext';
|
|
||||||
import { useCamera } from '../services/CameraContext';
|
|
||||||
import { useVoice } from '../services/VoiceContext';
|
|
||||||
|
|
||||||
const SettingsScreen: React.FC = () => {
|
|
||||||
const { user, logout } = useAuth();
|
|
||||||
const { isOnline, syncNow } = useOffline();
|
|
||||||
const { hasPermission: hasNotificationPermission, requestPermission: requestNotificationPermission } = useNotifications();
|
|
||||||
const { hasPermission: hasCameraPermission, requestPermission: requestCameraPermission, scanDocument } = useCamera();
|
|
||||||
const { hasPermission: hasVoicePermission, requestPermission: requestVoicePermission, isRecording, startRecording, stopRecording } = useVoice();
|
|
||||||
|
|
||||||
const [notifications, setNotifications] = React.useState(true);
|
|
||||||
const [darkMode, setDarkMode] = React.useState(false);
|
|
||||||
const [autoSync, setAutoSync] = React.useState(true);
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await logout();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNotificationPermission = async () => {
|
|
||||||
if (!hasNotificationPermission) {
|
|
||||||
const granted = await requestNotificationPermission();
|
|
||||||
if (granted) {
|
|
||||||
Alert.alert('Success', 'Notification permission granted!');
|
|
||||||
} else {
|
|
||||||
Alert.alert('Permission Denied', 'Notification permission is required for reminders');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCameraPermission = async () => {
|
|
||||||
if (!hasCameraPermission) {
|
|
||||||
const granted = await requestCameraPermission();
|
|
||||||
if (granted) {
|
|
||||||
Alert.alert('Success', 'Camera permission granted!');
|
|
||||||
} else {
|
|
||||||
Alert.alert('Permission Denied', 'Camera permission is required for document scanning');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVoicePermission = async () => {
|
|
||||||
if (!hasVoicePermission) {
|
|
||||||
const granted = await requestVoicePermission();
|
|
||||||
if (granted) {
|
|
||||||
Alert.alert('Success', 'Microphone permission granted!');
|
|
||||||
} else {
|
|
||||||
Alert.alert('Permission Denied', 'Microphone permission is required for voice recording');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTestNotification = () => {
|
|
||||||
// This would use the notification service to show a test notification
|
|
||||||
Alert.alert('Test Notification', 'This is a test notification!');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTestCamera = async () => {
|
|
||||||
try {
|
|
||||||
const result = await scanDocument();
|
|
||||||
if (result) {
|
|
||||||
Alert.alert('Success', 'Document scanned successfully!');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
Alert.alert('Error', 'Failed to scan document');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTestVoice = async () => {
|
|
||||||
if (isRecording) {
|
|
||||||
const recording = await stopRecording();
|
|
||||||
if (recording) {
|
|
||||||
Alert.alert('Success', `Voice recorded! Duration: ${recording.duration}s`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
startRecording();
|
|
||||||
Alert.alert('Recording', 'Voice recording started...');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<ScrollView style={styles.scrollView}>
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title>Account</Title>
|
|
||||||
<Text style={styles.userInfo}>
|
|
||||||
{user?.name} ({user?.email})
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
mode="outlined"
|
|
||||||
onPress={handleLogout}
|
|
||||||
style={styles.logoutButton}
|
|
||||||
>
|
|
||||||
Sign Out
|
|
||||||
</Button>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title>Preferences</Title>
|
|
||||||
|
|
||||||
<List.Item
|
|
||||||
title="Push Notifications"
|
|
||||||
description="Receive notifications for tasks and reminders"
|
|
||||||
right={() => (
|
|
||||||
<Switch
|
|
||||||
value={notifications}
|
|
||||||
onValueChange={setNotifications}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<List.Item
|
|
||||||
title="Dark Mode"
|
|
||||||
description="Use dark theme"
|
|
||||||
right={() => (
|
|
||||||
<Switch
|
|
||||||
value={darkMode}
|
|
||||||
onValueChange={setDarkMode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<List.Item
|
|
||||||
title="Auto Sync"
|
|
||||||
description="Automatically sync when online"
|
|
||||||
right={() => (
|
|
||||||
<Switch
|
|
||||||
value={autoSync}
|
|
||||||
onValueChange={setAutoSync}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title>📱 Mobile Features</Title>
|
|
||||||
|
|
||||||
<List.Item
|
|
||||||
title="Push Notifications"
|
|
||||||
description={hasNotificationPermission ? "Permission granted" : "Permission required"}
|
|
||||||
left={() => <Text style={styles.featureIcon}>🔔</Text>}
|
|
||||||
right={() => (
|
|
||||||
<View style={styles.featureActions}>
|
|
||||||
{!hasNotificationPermission && (
|
|
||||||
<Button
|
|
||||||
mode="outlined"
|
|
||||||
onPress={handleNotificationPermission}
|
|
||||||
compact
|
|
||||||
>
|
|
||||||
Enable
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{hasNotificationPermission && (
|
|
||||||
<Button
|
|
||||||
mode="text"
|
|
||||||
onPress={handleTestNotification}
|
|
||||||
compact
|
|
||||||
>
|
|
||||||
Test
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<List.Item
|
|
||||||
title="Camera & Document Scanning"
|
|
||||||
description={hasCameraPermission ? "Permission granted" : "Permission required"}
|
|
||||||
left={() => <Text style={styles.featureIcon}>📸</Text>}
|
|
||||||
right={() => (
|
|
||||||
<View style={styles.featureActions}>
|
|
||||||
{!hasCameraPermission && (
|
|
||||||
<Button
|
|
||||||
mode="outlined"
|
|
||||||
onPress={handleCameraPermission}
|
|
||||||
compact
|
|
||||||
>
|
|
||||||
Enable
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{hasCameraPermission && (
|
|
||||||
<Button
|
|
||||||
mode="text"
|
|
||||||
onPress={handleTestCamera}
|
|
||||||
compact
|
|
||||||
>
|
|
||||||
Test
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<List.Item
|
|
||||||
title="Voice Recording"
|
|
||||||
description={hasVoicePermission ? "Permission granted" : "Permission required"}
|
|
||||||
left={() => <Text style={styles.featureIcon}>🎤</Text>}
|
|
||||||
right={() => (
|
|
||||||
<View style={styles.featureActions}>
|
|
||||||
{!hasVoicePermission && (
|
|
||||||
<Button
|
|
||||||
mode="outlined"
|
|
||||||
onPress={handleVoicePermission}
|
|
||||||
compact
|
|
||||||
>
|
|
||||||
Enable
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{hasVoicePermission && (
|
|
||||||
<Button
|
|
||||||
mode={isRecording ? "contained" : "text"}
|
|
||||||
onPress={handleTestVoice}
|
|
||||||
compact
|
|
||||||
>
|
|
||||||
{isRecording ? "Stop" : "Test"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title>Sync Status</Title>
|
|
||||||
|
|
||||||
<List.Item
|
|
||||||
title="Connection"
|
|
||||||
description={isOnline ? 'Connected' : 'Offline'}
|
|
||||||
left={() => (
|
|
||||||
<Text style={styles.statusIcon}>
|
|
||||||
{isOnline ? '🟢' : '🔴'}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
mode="outlined"
|
|
||||||
onPress={syncNow}
|
|
||||||
disabled={!isOnline}
|
|
||||||
style={styles.syncButton}
|
|
||||||
>
|
|
||||||
Sync Now
|
|
||||||
</Button>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title>About</Title>
|
|
||||||
|
|
||||||
<List.Item
|
|
||||||
title="Version"
|
|
||||||
description="1.0.0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<List.Item
|
|
||||||
title="Build"
|
|
||||||
description="React Native Mobile App"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<List.Item
|
|
||||||
title="GitHub"
|
|
||||||
description="View source code"
|
|
||||||
onPress={() => console.log('Open GitHub')}
|
|
||||||
/>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
},
|
|
||||||
scrollView: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
margin: 16,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
userInfo: {
|
|
||||||
fontSize: 16,
|
|
||||||
marginBottom: 16,
|
|
||||||
color: '#666',
|
|
||||||
},
|
|
||||||
logoutButton: {
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
statusIcon: {
|
|
||||||
fontSize: 16,
|
|
||||||
width: 24,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
syncButton: {
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
featureIcon: {
|
|
||||||
fontSize: 16,
|
|
||||||
width: 24,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
featureActions: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default SettingsScreen;
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { View, StyleSheet, FlatList } from 'react-native';
|
|
||||||
import { Text, Card, Title, Paragraph, FAB, Checkbox } from 'react-native-paper';
|
|
||||||
|
|
||||||
const TasksScreen: React.FC = () => {
|
|
||||||
const [tasks, setTasks] = React.useState([
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
title: 'Complete mobile app setup',
|
|
||||||
description: 'Finish React Native project structure',
|
|
||||||
status: 'in_progress' as const,
|
|
||||||
priority: 'high' as const,
|
|
||||||
completed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
title: 'Review pull requests',
|
|
||||||
description: 'Check and merge pending PRs',
|
|
||||||
status: 'todo' as const,
|
|
||||||
priority: 'medium' as const,
|
|
||||||
completed: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const toggleTask = (taskId: string) => {
|
|
||||||
setTasks(prev =>
|
|
||||||
prev.map(task =>
|
|
||||||
task.id === taskId ? { ...task, completed: !task.completed } : task
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityColor = (priority: string) => {
|
|
||||||
switch (priority) {
|
|
||||||
case 'high': return '#f44336';
|
|
||||||
case 'medium': return '#ff9800';
|
|
||||||
case 'low': return '#4caf50';
|
|
||||||
default: return '#666';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderTask = ({ item }: any) => (
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<Card.Content>
|
|
||||||
<View style={styles.taskHeader}>
|
|
||||||
<Checkbox
|
|
||||||
status={item.completed ? 'checked' : 'unchecked'}
|
|
||||||
onPress={() => toggleTask(item.id)}
|
|
||||||
/>
|
|
||||||
<View style={styles.taskContent}>
|
|
||||||
<Title style={[styles.taskTitle, item.completed && styles.completedTitle]}>
|
|
||||||
{item.title}
|
|
||||||
</Title>
|
|
||||||
<Paragraph style={styles.taskDescription}>
|
|
||||||
{item.description}
|
|
||||||
</Paragraph>
|
|
||||||
<Text style={[styles.priority, { color: getPriorityColor(item.priority) }]}>
|
|
||||||
{item.priority.toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<FlatList
|
|
||||||
data={tasks}
|
|
||||||
renderItem={renderTask}
|
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
contentContainerStyle={styles.list}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FAB
|
|
||||||
icon="plus"
|
|
||||||
style={styles.fab}
|
|
||||||
onPress={() => console.log('Add task')}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
},
|
|
||||||
list: {
|
|
||||||
padding: 16,
|
|
||||||
paddingBottom: 80,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
marginBottom: 12,
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
taskHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
},
|
|
||||||
taskContent: {
|
|
||||||
flex: 1,
|
|
||||||
marginLeft: 12,
|
|
||||||
},
|
|
||||||
taskTitle: {
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
completedTitle: {
|
|
||||||
textDecorationLine: 'line-through',
|
|
||||||
color: '#666',
|
|
||||||
},
|
|
||||||
taskDescription: {
|
|
||||||
marginTop: 4,
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
priority: {
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
marginTop: 8,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
},
|
|
||||||
fab: {
|
|
||||||
position: 'absolute',
|
|
||||||
margin: 16,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: '#6200ee',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default TasksScreen;
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { View, StyleSheet } from 'react-native';
|
|
||||||
import { Text, Card, Title, Paragraph, Button, FAB } from 'react-native-paper';
|
|
||||||
|
|
||||||
const TimeTrackingScreen: React.FC = () => {
|
|
||||||
const [isTimerRunning, setIsTimerRunning] = useState(false);
|
|
||||||
const [elapsedTime, setElapsedTime] = useState(0);
|
|
||||||
const [currentTask, setCurrentTask] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let interval: NodeJS.Timeout;
|
|
||||||
if (isTimerRunning) {
|
|
||||||
interval = setInterval(() => {
|
|
||||||
setElapsedTime(prev => prev + 1);
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [isTimerRunning]);
|
|
||||||
|
|
||||||
const formatTime = (seconds: number) => {
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${hours.toString().padStart(2, '0')}:${minutes
|
|
||||||
.toString()
|
|
||||||
.padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleTimer = () => {
|
|
||||||
setIsTimerRunning(!isTimerRunning);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetTimer = () => {
|
|
||||||
setIsTimerRunning(false);
|
|
||||||
setElapsedTime(0);
|
|
||||||
setCurrentTask('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const timeEntries = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
description: 'Mobile app development',
|
|
||||||
duration: '2:30:00',
|
|
||||||
date: 'Today',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
description: 'Code review',
|
|
||||||
duration: '0:45:00',
|
|
||||||
date: 'Yesterday',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Card style={styles.timerCard}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title style={styles.timerTitle}>Time Tracker</Title>
|
|
||||||
<Text style={styles.timeDisplay}>{formatTime(elapsedTime)}</Text>
|
|
||||||
|
|
||||||
{currentTask ? (
|
|
||||||
<Paragraph style={styles.currentTask}>
|
|
||||||
Working on: {currentTask}
|
|
||||||
</Paragraph>
|
|
||||||
) : (
|
|
||||||
<Paragraph style={styles.noTask}>
|
|
||||||
No task selected
|
|
||||||
</Paragraph>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={styles.timerButtons}>
|
|
||||||
<Button
|
|
||||||
mode={isTimerRunning ? 'outlined' : 'contained'}
|
|
||||||
onPress={toggleTimer}
|
|
||||||
style={styles.timerButton}
|
|
||||||
>
|
|
||||||
{isTimerRunning ? 'Pause' : 'Start'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
mode="outlined"
|
|
||||||
onPress={resetTimer}
|
|
||||||
style={styles.timerButton}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card style={styles.entriesCard}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title>Recent Entries</Title>
|
|
||||||
{timeEntries.map(entry => (
|
|
||||||
<View key={entry.id} style={styles.entryItem}>
|
|
||||||
<View style={styles.entryContent}>
|
|
||||||
<Text style={styles.entryDescription}>
|
|
||||||
{entry.description}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.entryDuration}>
|
|
||||||
{entry.duration}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.entryDate}>{entry.date}</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<FAB
|
|
||||||
icon="plus"
|
|
||||||
style={styles.fab}
|
|
||||||
onPress={() => console.log('Add time entry')}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
timerCard: {
|
|
||||||
marginBottom: 16,
|
|
||||||
elevation: 4,
|
|
||||||
},
|
|
||||||
timerTitle: {
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
timeDisplay: {
|
|
||||||
fontSize: 48,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
textAlign: 'center',
|
|
||||||
color: '#6200ee',
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
currentTask: {
|
|
||||||
textAlign: 'center',
|
|
||||||
color: '#666',
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
noTask: {
|
|
||||||
textAlign: 'center',
|
|
||||||
color: '#999',
|
|
||||||
fontStyle: 'italic',
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
timerButtons: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-around',
|
|
||||||
},
|
|
||||||
timerButton: {
|
|
||||||
flex: 1,
|
|
||||||
marginHorizontal: 8,
|
|
||||||
},
|
|
||||||
entriesCard: {
|
|
||||||
elevation: 2,
|
|
||||||
},
|
|
||||||
entryItem: {
|
|
||||||
paddingVertical: 12,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: '#eee',
|
|
||||||
},
|
|
||||||
entryContent: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
entryDescription: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
entryDuration: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#6200ee',
|
|
||||||
},
|
|
||||||
entryDate: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#666',
|
|
||||||
marginTop: 4,
|
|
||||||
},
|
|
||||||
fab: {
|
|
||||||
position: 'absolute',
|
|
||||||
margin: 16,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: '#6200ee',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default TimeTrackingScreen;
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
StyleSheet,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
Alert,
|
|
||||||
} from 'react-native';
|
|
||||||
import {
|
|
||||||
TextInput,
|
|
||||||
Button,
|
|
||||||
Text,
|
|
||||||
Card,
|
|
||||||
Title,
|
|
||||||
Paragraph,
|
|
||||||
} from 'react-native-paper';
|
|
||||||
import { useAuth } from '../../services/AuthContext';
|
|
||||||
|
|
||||||
const LoginScreen: React.FC = ({ navigation }: any) => {
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const { login, loginWithGitHub } = useAuth();
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
if (!email || !password) {
|
|
||||||
Alert.alert('Error', 'Please fill in all fields');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const success = await login(email, password);
|
|
||||||
if (!success) {
|
|
||||||
Alert.alert('Error', 'Invalid email or password');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
Alert.alert('Error', 'Login failed. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGitHubLogin = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const success = await loginWithGitHub();
|
|
||||||
if (!success) {
|
|
||||||
Alert.alert('Error', 'GitHub login failed');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
Alert.alert('Error', 'GitHub login failed. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
style={styles.container}
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
||||||
>
|
|
||||||
<View style={styles.content}>
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title style={styles.title}>Welcome to Trackeep</Title>
|
|
||||||
<Paragraph style={styles.subtitle}>
|
|
||||||
Your productivity and knowledge management companion
|
|
||||||
</Paragraph>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="Email"
|
|
||||||
value={email}
|
|
||||||
onChangeText={setEmail}
|
|
||||||
mode="outlined"
|
|
||||||
keyboardType="email-address"
|
|
||||||
autoCapitalize="none"
|
|
||||||
style={styles.input}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="Password"
|
|
||||||
value={password}
|
|
||||||
onChangeText={setPassword}
|
|
||||||
mode="outlined"
|
|
||||||
secureTextEntry={!showPassword}
|
|
||||||
right={
|
|
||||||
<TextInput.Icon
|
|
||||||
icon={showPassword ? 'eye-off' : 'eye'}
|
|
||||||
onPress={() => setShowPassword(!showPassword)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
style={styles.input}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
mode="contained"
|
|
||||||
onPress={handleLogin}
|
|
||||||
loading={loading}
|
|
||||||
disabled={loading}
|
|
||||||
style={styles.button}
|
|
||||||
>
|
|
||||||
Sign In
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<View style={styles.divider}>
|
|
||||||
<Text style={styles.dividerText}>OR</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
mode="outlined"
|
|
||||||
onPress={handleGitHubLogin}
|
|
||||||
loading={loading}
|
|
||||||
disabled={loading}
|
|
||||||
style={styles.githubButton}
|
|
||||||
icon="github"
|
|
||||||
>
|
|
||||||
Continue with GitHub
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
mode="text"
|
|
||||||
onPress={() => navigation.navigate('Register')}
|
|
||||||
style={styles.linkButton}
|
|
||||||
>
|
|
||||||
Don't have an account? Sign Up
|
|
||||||
</Button>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 20,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
elevation: 4,
|
|
||||||
borderRadius: 12,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: 8,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: 24,
|
|
||||||
color: '#666',
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
marginBottom: 16,
|
|
||||||
paddingVertical: 8,
|
|
||||||
},
|
|
||||||
divider: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginVertical: 16,
|
|
||||||
},
|
|
||||||
dividerText: {
|
|
||||||
flex: 1,
|
|
||||||
textAlign: 'center',
|
|
||||||
color: '#666',
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
githubButton: {
|
|
||||||
marginBottom: 16,
|
|
||||||
paddingVertical: 8,
|
|
||||||
},
|
|
||||||
linkButton: {
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default LoginScreen;
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
StyleSheet,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
Alert,
|
|
||||||
} from 'react-native';
|
|
||||||
import {
|
|
||||||
TextInput,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Title,
|
|
||||||
Paragraph,
|
|
||||||
} from 'react-native-paper';
|
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
||||||
import { AuthStackParamList } from '../../navigation/AuthNavigator';
|
|
||||||
|
|
||||||
type RegisterScreenNavigationProp = NativeStackNavigationProp<
|
|
||||||
AuthStackParamList,
|
|
||||||
'Register'
|
|
||||||
>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
navigation: RegisterScreenNavigationProp;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RegisterScreen: React.FC<Props> = ({ navigation }) => {
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
|
||||||
|
|
||||||
const handleRegister = async () => {
|
|
||||||
if (!name || !email || !password || !confirmPassword) {
|
|
||||||
Alert.alert('Error', 'Please fill in all fields');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
Alert.alert('Error', 'Passwords do not match');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 6) {
|
|
||||||
Alert.alert('Error', 'Password must be at least 6 characters');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
Alert.alert('Success', 'Registration successful! Please sign in.');
|
|
||||||
navigation.navigate('Login');
|
|
||||||
} catch (error) {
|
|
||||||
Alert.alert('Error', 'Registration failed. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
style={styles.container}
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
||||||
>
|
|
||||||
<View style={styles.content}>
|
|
||||||
<Card style={styles.card}>
|
|
||||||
<Card.Content>
|
|
||||||
<Title style={styles.title}>Create Account</Title>
|
|
||||||
<Paragraph style={styles.subtitle}>
|
|
||||||
Join Trackeep and boost your productivity
|
|
||||||
</Paragraph>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="Full Name"
|
|
||||||
value={name}
|
|
||||||
onChangeText={setName}
|
|
||||||
mode="outlined"
|
|
||||||
autoCapitalize="words"
|
|
||||||
style={styles.input}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="Email"
|
|
||||||
value={email}
|
|
||||||
onChangeText={setEmail}
|
|
||||||
mode="outlined"
|
|
||||||
keyboardType="email-address"
|
|
||||||
autoCapitalize="none"
|
|
||||||
style={styles.input}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="Password"
|
|
||||||
value={password}
|
|
||||||
onChangeText={setPassword}
|
|
||||||
mode="outlined"
|
|
||||||
secureTextEntry={!showPassword}
|
|
||||||
right={
|
|
||||||
<TextInput.Icon
|
|
||||||
icon={showPassword ? 'eye-off' : 'eye'}
|
|
||||||
onPress={() => setShowPassword(!showPassword)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
style={styles.input}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="Confirm Password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChangeText={setConfirmPassword}
|
|
||||||
mode="outlined"
|
|
||||||
secureTextEntry={!showConfirmPassword}
|
|
||||||
right={
|
|
||||||
<TextInput.Icon
|
|
||||||
icon={showConfirmPassword ? 'eye-off' : 'eye'}
|
|
||||||
onPress={() => setShowConfirmPassword(!showConfirmPassword)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
style={styles.input}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
mode="contained"
|
|
||||||
onPress={handleRegister}
|
|
||||||
loading={loading}
|
|
||||||
disabled={loading}
|
|
||||||
style={styles.button}
|
|
||||||
>
|
|
||||||
Sign Up
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
mode="text"
|
|
||||||
onPress={() => navigation.navigate('Login')}
|
|
||||||
style={styles.linkButton}
|
|
||||||
>
|
|
||||||
Already have an account? Sign In
|
|
||||||
</Button>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 20,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
elevation: 4,
|
|
||||||
borderRadius: 12,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: 8,
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: 24,
|
|
||||||
color: '#666',
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
marginBottom: 16,
|
|
||||||
paddingVertical: 8,
|
|
||||||
},
|
|
||||||
linkButton: {
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default RegisterScreen;
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
|
||||||
import { User, NavigationState } from '../types';
|
|
||||||
import { authAPI } from './api';
|
|
||||||
import { storeAuthData, getStoredAuthData, clearAuthData } from '../utils/storage';
|
|
||||||
|
|
||||||
interface AuthContextType extends NavigationState {
|
|
||||||
login: (email: string, password: string) => Promise<boolean>;
|
|
||||||
loginWithGitHub: () => Promise<boolean>;
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
updateUser: (user: Partial<User>) => Promise<boolean>;
|
|
||||||
refreshUser: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
interface AuthProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|
||||||
const [state, setState] = useState<NavigationState>({
|
|
||||||
isAuthenticated: false,
|
|
||||||
isLoading: true,
|
|
||||||
user: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initializeAuth();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const initializeAuth = async () => {
|
|
||||||
try {
|
|
||||||
const storedAuth = await getStoredAuthData();
|
|
||||||
if (storedAuth && storedAuth.token) {
|
|
||||||
const userResponse = await authAPI.getCurrentUser(storedAuth.token);
|
|
||||||
if (userResponse.success && userResponse.data) {
|
|
||||||
setState({
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false,
|
|
||||||
user: userResponse.data,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await clearAuthData();
|
|
||||||
setState({
|
|
||||||
isAuthenticated: false,
|
|
||||||
isLoading: false,
|
|
||||||
user: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setState({
|
|
||||||
isAuthenticated: false,
|
|
||||||
isLoading: false,
|
|
||||||
user: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Auth initialization error:', error);
|
|
||||||
await clearAuthData();
|
|
||||||
setState({
|
|
||||||
isAuthenticated: false,
|
|
||||||
isLoading: false,
|
|
||||||
user: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const login = async (email: string, password: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
setState(prev => ({ ...prev, isLoading: true }));
|
|
||||||
|
|
||||||
const response = await authAPI.login(email, password);
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
await storeAuthData({
|
|
||||||
token: response.data.token,
|
|
||||||
user: response.data.user,
|
|
||||||
});
|
|
||||||
|
|
||||||
setState({
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false,
|
|
||||||
user: response.data.user,
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(prev => ({ ...prev, isLoading: false }));
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login error:', error);
|
|
||||||
setState(prev => ({ ...prev, isLoading: false }));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loginWithGitHub = async (): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
setState(prev => ({ ...prev, isLoading: true }));
|
|
||||||
|
|
||||||
const response = await authAPI.loginWithGitHub();
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
await storeAuthData({
|
|
||||||
token: response.data.token,
|
|
||||||
user: response.data.user,
|
|
||||||
});
|
|
||||||
|
|
||||||
setState({
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false,
|
|
||||||
user: response.data.user,
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(prev => ({ ...prev, isLoading: false }));
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('GitHub login error:', error);
|
|
||||||
setState(prev => ({ ...prev, isLoading: false }));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const logout = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await clearAuthData();
|
|
||||||
setState({
|
|
||||||
isAuthenticated: false,
|
|
||||||
isLoading: false,
|
|
||||||
user: undefined,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Logout error:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateUser = async (updates: Partial<User>): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
if (!state.user) return false;
|
|
||||||
|
|
||||||
const response = await authAPI.updateUser(state.user.id, updates);
|
|
||||||
|
|
||||||
if (response.success && response.data) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
user: { ...prev.user!, ...response.data },
|
|
||||||
}));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Update user error:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshUser = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const storedAuth = await getStoredAuthData();
|
|
||||||
if (storedAuth && storedAuth.token) {
|
|
||||||
const userResponse = await authAPI.getCurrentUser(storedAuth.token);
|
|
||||||
if (userResponse.success && userResponse.data) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
user: userResponse.data,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Refresh user error:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const value: AuthContextType = {
|
|
||||||
...state,
|
|
||||||
login,
|
|
||||||
loginWithGitHub,
|
|
||||||
logout,
|
|
||||||
updateUser,
|
|
||||||
refreshUser,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useAuth = (): AuthContextType => {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
|
||||||
import { Alert, Platform } from 'react-native';
|
|
||||||
import { useCameraDevices } from 'react-native-vision-camera';
|
|
||||||
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
|
|
||||||
|
|
||||||
interface CameraContextType {
|
|
||||||
hasPermission: boolean;
|
|
||||||
devices: any;
|
|
||||||
isActive: boolean;
|
|
||||||
requestPermission: () => Promise<boolean>;
|
|
||||||
startCamera: () => void;
|
|
||||||
stopCamera: () => void;
|
|
||||||
capturePhoto: () => Promise<string | null>;
|
|
||||||
scanDocument: () => Promise<string | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CameraContext = createContext<CameraContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
interface CameraProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CameraProvider: React.FC<CameraProviderProps> = ({ children }) => {
|
|
||||||
const [hasPermission, setHasPermission] = useState(false);
|
|
||||||
const [isActive, setIsActive] = useState(false);
|
|
||||||
const devices = useCameraDevices();
|
|
||||||
const device = devices.find(d => d.position === 'back');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
checkPermission();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const checkPermission = async () => {
|
|
||||||
const permission = Platform.OS === 'ios'
|
|
||||||
? PERMISSIONS.IOS.CAMERA
|
|
||||||
: PERMISSIONS.ANDROID.CAMERA;
|
|
||||||
|
|
||||||
const result = await request(permission);
|
|
||||||
setHasPermission(result === RESULTS.GRANTED);
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestPermission = async (): Promise<boolean> => {
|
|
||||||
const permission = Platform.OS === 'ios'
|
|
||||||
? PERMISSIONS.IOS.CAMERA
|
|
||||||
: PERMISSIONS.ANDROID.CAMERA;
|
|
||||||
|
|
||||||
const result = await request(permission);
|
|
||||||
const granted = result === RESULTS.GRANTED;
|
|
||||||
setHasPermission(granted);
|
|
||||||
return granted;
|
|
||||||
};
|
|
||||||
|
|
||||||
const startCamera = () => {
|
|
||||||
if (hasPermission && device) {
|
|
||||||
setIsActive(true);
|
|
||||||
} else {
|
|
||||||
Alert.alert('Camera Error', 'Camera permission is required or no camera available');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopCamera = () => {
|
|
||||||
setIsActive(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const capturePhoto = async (): Promise<string | null> => {
|
|
||||||
if (!device || !isActive) {
|
|
||||||
Alert.alert('Camera Error', 'Camera is not active');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// This would need to be implemented with actual camera capture logic
|
|
||||||
// For now, return a placeholder
|
|
||||||
const photo = 'captured-photo-path';
|
|
||||||
return photo;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error capturing photo:', error);
|
|
||||||
Alert.alert('Error', 'Failed to capture photo');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scanDocument = async (): Promise<string | null> => {
|
|
||||||
if (!hasPermission) {
|
|
||||||
const granted = await requestPermission();
|
|
||||||
if (!granted) {
|
|
||||||
Alert.alert('Permission Required', 'Camera access is required for document scanning');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Start camera for document scanning
|
|
||||||
startCamera();
|
|
||||||
|
|
||||||
// This would integrate with a document scanning library
|
|
||||||
// For now, return a placeholder
|
|
||||||
const scannedDocument = 'scanned-document-path';
|
|
||||||
|
|
||||||
// Stop camera after scanning
|
|
||||||
stopCamera();
|
|
||||||
|
|
||||||
return scannedDocument;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error scanning document:', error);
|
|
||||||
Alert.alert('Error', 'Failed to scan document');
|
|
||||||
stopCamera();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const value: CameraContextType = {
|
|
||||||
hasPermission,
|
|
||||||
devices,
|
|
||||||
isActive,
|
|
||||||
requestPermission,
|
|
||||||
startCamera,
|
|
||||||
stopCamera,
|
|
||||||
capturePhoto,
|
|
||||||
scanDocument,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CameraContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</CameraContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCamera = (): CameraContextType => {
|
|
||||||
const context = useContext(CameraContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useCamera must be used within a CameraProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
|
||||||
import PushNotification from 'react-native-push-notification';
|
|
||||||
import { Platform, Alert } from 'react-native';
|
|
||||||
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
|
|
||||||
|
|
||||||
interface Notification {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
date?: Date;
|
|
||||||
userInfo?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NotificationContextType {
|
|
||||||
isInitialized: boolean;
|
|
||||||
hasPermission: boolean;
|
|
||||||
requestPermission: () => Promise<boolean>;
|
|
||||||
scheduleNotification: (notification: Notification) => void;
|
|
||||||
cancelNotification: (id: string) => void;
|
|
||||||
cancelAllNotifications: () => void;
|
|
||||||
showLocalNotification: (title: string, message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
interface NotificationProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NotificationProvider: React.FC<NotificationProviderProps> = ({ children }) => {
|
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
|
||||||
const [hasPermission, setHasPermission] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initializeNotifications();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const initializeNotifications = () => {
|
|
||||||
PushNotification.configure({
|
|
||||||
onRegister: (token) => {
|
|
||||||
console.log('Push notification token:', token);
|
|
||||||
// TODO: Send token to backend for server-side notifications
|
|
||||||
},
|
|
||||||
|
|
||||||
onNotification: (notification) => {
|
|
||||||
console.log('Notification received:', notification);
|
|
||||||
|
|
||||||
if (notification.userInteraction) {
|
|
||||||
// User tapped on notification
|
|
||||||
handleNotificationPress(notification);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
permissions: {
|
|
||||||
alert: true,
|
|
||||||
badge: true,
|
|
||||||
sound: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
popInitialNotification: true,
|
|
||||||
requestPermissions: Platform.OS === 'ios',
|
|
||||||
});
|
|
||||||
|
|
||||||
PushNotification.createChannel(
|
|
||||||
'trackeep-tasks',
|
|
||||||
'Task Reminders',
|
|
||||||
4,
|
|
||||||
(created: any) => console.log('Task channel created:', created)
|
|
||||||
);
|
|
||||||
|
|
||||||
PushNotification.createChannel(
|
|
||||||
'trackeep-general',
|
|
||||||
'General Notifications',
|
|
||||||
3,
|
|
||||||
(created: any) => console.log('General channel created:', created)
|
|
||||||
);
|
|
||||||
|
|
||||||
checkPermission();
|
|
||||||
setIsInitialized(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkPermission = async () => {
|
|
||||||
if (Platform.OS === 'ios') {
|
|
||||||
PushNotification.checkPermissions((permissions) => {
|
|
||||||
setHasPermission(Boolean(permissions.alert || permissions.badge || permissions.sound));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const permission = PERMISSIONS.ANDROID.POST_NOTIFICATIONS;
|
|
||||||
const result = await request(permission);
|
|
||||||
setHasPermission(result === RESULTS.GRANTED);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestPermission = async (): Promise<boolean> => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (Platform.OS === 'ios') {
|
|
||||||
PushNotification.requestPermissions((permissions: any) => {
|
|
||||||
const granted = permissions.alert || permissions.badge || permissions.sound;
|
|
||||||
setHasPermission(granted);
|
|
||||||
resolve(granted);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
request(PERMISSIONS.ANDROID.POST_NOTIFICATIONS).then((result) => {
|
|
||||||
const granted = result === RESULTS.GRANTED;
|
|
||||||
setHasPermission(granted);
|
|
||||||
resolve(granted);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleNotification = (notification: Notification) => {
|
|
||||||
if (!hasPermission) {
|
|
||||||
Alert.alert('Permission Required', 'Please enable notifications to receive reminders.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
PushNotification.localNotificationSchedule({
|
|
||||||
channelId: 'trackeep-tasks',
|
|
||||||
id: parseInt(notification.id),
|
|
||||||
title: notification.title,
|
|
||||||
message: notification.message,
|
|
||||||
date: notification.date || new Date(),
|
|
||||||
allowWhileIdle: true,
|
|
||||||
userInfo: notification.userInfo,
|
|
||||||
actions: ['View', 'Dismiss'],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelNotification = (id: string) => {
|
|
||||||
PushNotification.cancelLocalNotifications({ id: id.toString() });
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelAllNotifications = () => {
|
|
||||||
PushNotification.cancelAllLocalNotifications();
|
|
||||||
};
|
|
||||||
|
|
||||||
const showLocalNotification = (title: string, message: string) => {
|
|
||||||
PushNotification.localNotification({
|
|
||||||
channelId: 'trackeep-general',
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
actions: ['View', 'Dismiss'],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNotificationPress = (notification: any) => {
|
|
||||||
// TODO: Navigate to relevant screen based on notification data
|
|
||||||
console.log('Notification pressed:', notification);
|
|
||||||
};
|
|
||||||
|
|
||||||
const value: NotificationContextType = {
|
|
||||||
isInitialized,
|
|
||||||
hasPermission,
|
|
||||||
requestPermission,
|
|
||||||
scheduleNotification,
|
|
||||||
cancelNotification,
|
|
||||||
cancelAllNotifications,
|
|
||||||
showLocalNotification,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NotificationContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</NotificationContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useNotifications = (): NotificationContextType => {
|
|
||||||
const context = useContext(NotificationContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useNotifications must be used within a NotificationProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
|
||||||
import { OfflineState } from '../types';
|
|
||||||
import NetInfo from '@react-native-community/netinfo';
|
|
||||||
import { syncOfflineData, getPendingChangesCount } from '../utils/offlineSync';
|
|
||||||
|
|
||||||
interface OfflineContextType extends OfflineState {
|
|
||||||
syncNow: () => Promise<void>;
|
|
||||||
forceSync: () => Promise<void>;
|
|
||||||
clearPendingChanges: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OfflineContext = createContext<OfflineContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
interface OfflineProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OfflineProvider: React.FC<OfflineProviderProps> = ({ children }) => {
|
|
||||||
const [state, setState] = useState<OfflineState>({
|
|
||||||
isOnline: true,
|
|
||||||
syncInProgress: false,
|
|
||||||
pendingChanges: 0,
|
|
||||||
lastSyncTime: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = NetInfo.addEventListener((netState: any) => {
|
|
||||||
const isOnline = netState.isConnected ?? false;
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
isOnline
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (isOnline && state.pendingChanges > 0) {
|
|
||||||
syncOfflineData();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
loadPendingChanges();
|
|
||||||
|
|
||||||
return () => unsubscribe();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadPendingChanges = async () => {
|
|
||||||
try {
|
|
||||||
const count = await getPendingChangesCount();
|
|
||||||
setState(prev => ({ ...prev, pendingChanges: count }));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading pending changes:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncNow = async () => {
|
|
||||||
if (!state.isOnline || state.syncInProgress) return;
|
|
||||||
|
|
||||||
setState(prev => ({ ...prev, syncInProgress: true }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await syncOfflineData();
|
|
||||||
const count = await getPendingChangesCount();
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
syncInProgress: false,
|
|
||||||
pendingChanges: count,
|
|
||||||
lastSyncTime: new Date(),
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Sync error:', error);
|
|
||||||
setState(prev => ({ ...prev, syncInProgress: false }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const forceSync = async () => {
|
|
||||||
setState(prev => ({ ...prev, syncInProgress: true }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await syncOfflineData();
|
|
||||||
const count = await getPendingChangesCount();
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
syncInProgress: false,
|
|
||||||
pendingChanges: count,
|
|
||||||
lastSyncTime: new Date(),
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Force sync error:', error);
|
|
||||||
setState(prev => ({ ...prev, syncInProgress: false }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearPendingChanges = async () => {
|
|
||||||
try {
|
|
||||||
setState(prev => ({ ...prev, pendingChanges: 0 }));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error clearing pending changes:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const value: OfflineContextType = {
|
|
||||||
...state,
|
|
||||||
syncNow,
|
|
||||||
forceSync,
|
|
||||||
clearPendingChanges,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <OfflineContext.Provider value={value}>{children}</OfflineContext.Provider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useOffline = (): OfflineContextType => {
|
|
||||||
const context = useContext(OfflineContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useOffline must be used within an OfflineProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
|
||||||
import { useNetInfo } from '@react-native-community/netinfo';
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import { useServerConfig } from './ServerConfigContext';
|
|
||||||
import { DeviceEventEmitter } from 'react-native';
|
|
||||||
|
|
||||||
interface SyncEvent {
|
|
||||||
id: string;
|
|
||||||
type: 'create' | 'update' | 'delete';
|
|
||||||
entityType: 'bookmark' | 'task' | 'note' | 'timeEntry';
|
|
||||||
entityId: string;
|
|
||||||
data: any;
|
|
||||||
timestamp: number;
|
|
||||||
synced: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RealtimeSyncContextType {
|
|
||||||
isOnline: boolean;
|
|
||||||
isSyncing: boolean;
|
|
||||||
pendingEvents: SyncEvent[];
|
|
||||||
lastSyncTime: number | null;
|
|
||||||
syncNow: () => Promise<void>;
|
|
||||||
addSyncEvent: (event: Omit<SyncEvent, 'id' | 'timestamp' | 'synced'>) => Promise<void>;
|
|
||||||
clearPendingEvents: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RealtimeSyncContext = createContext<RealtimeSyncContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
const SYNC_EVENTS_KEY = 'trackeep_sync_events';
|
|
||||||
const LAST_SYNC_KEY = 'trackeep_last_sync';
|
|
||||||
|
|
||||||
interface RealtimeSyncProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RealtimeSyncProvider: React.FC<RealtimeSyncProviderProps> = ({ children }) => {
|
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
|
||||||
const [pendingEvents, setPendingEvents] = useState<SyncEvent[]>([]);
|
|
||||||
const [lastSyncTime, setLastSyncTime] = useState<number | null>(null);
|
|
||||||
const [websocket, setWebsocket] = useState<WebSocket | null>(null);
|
|
||||||
|
|
||||||
const netInfo = useNetInfo();
|
|
||||||
const { config } = useServerConfig();
|
|
||||||
|
|
||||||
const isOnline = netInfo.isConnected === true;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadSyncData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOnline && config && pendingEvents.length > 0) {
|
|
||||||
syncPendingEvents();
|
|
||||||
}
|
|
||||||
}, [isOnline, config, pendingEvents.length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOnline && config) {
|
|
||||||
connectWebSocket();
|
|
||||||
} else {
|
|
||||||
disconnectWebSocket();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disconnectWebSocket();
|
|
||||||
};
|
|
||||||
}, [isOnline, config]);
|
|
||||||
|
|
||||||
const loadSyncData = async () => {
|
|
||||||
try {
|
|
||||||
const storedEvents = await AsyncStorage.getItem(SYNC_EVENTS_KEY);
|
|
||||||
const storedLastSync = await AsyncStorage.getItem(LAST_SYNC_KEY);
|
|
||||||
|
|
||||||
if (storedEvents) {
|
|
||||||
const events = JSON.parse(storedEvents);
|
|
||||||
setPendingEvents(events);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storedLastSync) {
|
|
||||||
setLastSyncTime(JSON.parse(storedLastSync));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading sync data:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const connectWebSocket = () => {
|
|
||||||
if (!config) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const wsUrl = config.baseUrl.replace('http', 'ws') + '/ws';
|
|
||||||
const ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
console.log('WebSocket connected');
|
|
||||||
setWebsocket(ws);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
handleRealtimeUpdate(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing WebSocket message:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
console.log('WebSocket disconnected');
|
|
||||||
setWebsocket(null);
|
|
||||||
// Attempt to reconnect after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
if (isOnline && config) {
|
|
||||||
connectWebSocket();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error connecting WebSocket:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const disconnectWebSocket = () => {
|
|
||||||
if (websocket) {
|
|
||||||
websocket.close();
|
|
||||||
setWebsocket(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRealtimeUpdate = (data: any) => {
|
|
||||||
// This will be handled by individual components through event listeners
|
|
||||||
console.log('Received realtime update:', data);
|
|
||||||
|
|
||||||
// Emit a custom event that components can listen to
|
|
||||||
DeviceEventEmitter.emit('trackeep:sync', data);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addSyncEvent = async (event: Omit<SyncEvent, 'id' | 'timestamp' | 'synced'>) => {
|
|
||||||
const syncEvent: SyncEvent = {
|
|
||||||
...event,
|
|
||||||
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
|
|
||||||
timestamp: Date.now(),
|
|
||||||
synced: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatedEvents = [...pendingEvents, syncEvent];
|
|
||||||
setPendingEvents(updatedEvents);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await AsyncStorage.setItem(SYNC_EVENTS_KEY, JSON.stringify(updatedEvents));
|
|
||||||
|
|
||||||
// Try to sync immediately if online
|
|
||||||
if (isOnline && config) {
|
|
||||||
await syncPendingEvents();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving sync event:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncPendingEvents = async () => {
|
|
||||||
if (!config || isSyncing || pendingEvents.length === 0) return;
|
|
||||||
|
|
||||||
setIsSyncing(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const unsyncedEvents = pendingEvents.filter(event => !event.synced);
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
unsyncedEvents.map(event => syncSingleEvent(event))
|
|
||||||
);
|
|
||||||
|
|
||||||
const successfulEvents: string[] = [];
|
|
||||||
results.forEach((result, index) => {
|
|
||||||
if (result.status === 'fulfilled' && result.value) {
|
|
||||||
successfulEvents.push(unsyncedEvents[index].id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update pending events to mark successful ones as synced
|
|
||||||
const updatedEvents = pendingEvents.map(event => ({
|
|
||||||
...event,
|
|
||||||
synced: successfulEvents.includes(event.id),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Remove synced events after a delay
|
|
||||||
const finalEvents = updatedEvents.filter(event => !event.synced);
|
|
||||||
setPendingEvents(finalEvents);
|
|
||||||
|
|
||||||
await AsyncStorage.setItem(SYNC_EVENTS_KEY, JSON.stringify(finalEvents));
|
|
||||||
|
|
||||||
// Update last sync time
|
|
||||||
const now = Date.now();
|
|
||||||
setLastSyncTime(now);
|
|
||||||
await AsyncStorage.setItem(LAST_SYNC_KEY, JSON.stringify(now));
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error during sync:', error);
|
|
||||||
} finally {
|
|
||||||
setIsSyncing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncSingleEvent = async (event: SyncEvent): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const token = await AsyncStorage.getItem('trackeep_auth_token');
|
|
||||||
if (!token || !config) return false;
|
|
||||||
|
|
||||||
const response = await fetch(`${config.baseUrl}/api/sync/${event.entityType}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
type: event.type,
|
|
||||||
id: event.entityId,
|
|
||||||
data: event.data,
|
|
||||||
timestamp: event.timestamp,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error syncing single event:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncNow = async () => {
|
|
||||||
await syncPendingEvents();
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearPendingEvents = async () => {
|
|
||||||
setPendingEvents([]);
|
|
||||||
try {
|
|
||||||
await AsyncStorage.removeItem(SYNC_EVENTS_KEY);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error clearing pending events:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const value: RealtimeSyncContextType = {
|
|
||||||
isOnline,
|
|
||||||
isSyncing,
|
|
||||||
pendingEvents,
|
|
||||||
lastSyncTime,
|
|
||||||
syncNow,
|
|
||||||
addSyncEvent,
|
|
||||||
clearPendingEvents,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RealtimeSyncContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</RealtimeSyncContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useRealtimeSync = (): RealtimeSyncContextType => {
|
|
||||||
const context = useContext(RealtimeSyncContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useRealtimeSync must be used within a RealtimeSyncProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hook for components to listen to realtime updates
|
|
||||||
export const useRealtimeUpdates = (callback: (data: any) => void) => {
|
|
||||||
useEffect(() => {
|
|
||||||
const subscription = DeviceEventEmitter.addListener('trackeep:sync', callback);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscription.remove();
|
|
||||||
};
|
|
||||||
}, [callback]);
|
|
||||||
};
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
|
|
||||||
interface ServerConfig {
|
|
||||||
baseUrl: string;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServerConfigContextType {
|
|
||||||
config: ServerConfig | null;
|
|
||||||
isConfigured: boolean;
|
|
||||||
setConfig: (config: ServerConfig) => Promise<void>;
|
|
||||||
clearConfig: () => Promise<void>;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ServerConfigContext = createContext<ServerConfigContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
const SERVER_CONFIG_KEY = 'trackeep_server_config';
|
|
||||||
|
|
||||||
interface ServerConfigProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ServerConfigProvider: React.FC<ServerConfigProviderProps> = ({ children }) => {
|
|
||||||
const [config, setConfigState] = useState<ServerConfig | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadConfig();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadConfig = async () => {
|
|
||||||
try {
|
|
||||||
const storedConfig = await AsyncStorage.getItem(SERVER_CONFIG_KEY);
|
|
||||||
if (storedConfig) {
|
|
||||||
const parsedConfig = JSON.parse(storedConfig);
|
|
||||||
setConfigState(parsedConfig);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading server config:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setConfig = async (newConfig: ServerConfig) => {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.setItem(SERVER_CONFIG_KEY, JSON.stringify(newConfig));
|
|
||||||
setConfigState(newConfig);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving server config:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearConfig = async () => {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.removeItem(SERVER_CONFIG_KEY);
|
|
||||||
setConfigState(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error clearing server config:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const value: ServerConfigContextType = {
|
|
||||||
config,
|
|
||||||
isConfigured: !!config,
|
|
||||||
setConfig,
|
|
||||||
clearConfig,
|
|
||||||
isLoading,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ServerConfigContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</ServerConfigContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useServerConfig = (): ServerConfigContextType => {
|
|
||||||
const context = useContext(ServerConfigContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useServerConfig must be used within a ServerConfigProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
|
||||||
import { Alert, Platform } from 'react-native';
|
|
||||||
import { request, PERMISSIONS, RESULTS } from 'react-native-permissions';
|
|
||||||
import Voice from 'react-native-voice';
|
|
||||||
|
|
||||||
interface VoiceRecording {
|
|
||||||
id: string;
|
|
||||||
path: string;
|
|
||||||
duration: number;
|
|
||||||
transcript?: string;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VoiceContextType {
|
|
||||||
isRecording: boolean;
|
|
||||||
isProcessing: boolean;
|
|
||||||
hasPermission: boolean;
|
|
||||||
recordings: VoiceRecording[];
|
|
||||||
requestPermission: () => Promise<boolean>;
|
|
||||||
startRecording: () => void;
|
|
||||||
stopRecording: () => Promise<VoiceRecording | null>;
|
|
||||||
transcribeRecording: (recordingPath: string) => Promise<string | null>;
|
|
||||||
deleteRecording: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VoiceContext = createContext<VoiceContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
interface VoiceProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VoiceProvider: React.FC<VoiceProviderProps> = ({ children }) => {
|
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
const [hasPermission, setHasPermission] = useState(false);
|
|
||||||
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
|
||||||
const [recordingStartTime, setRecordingStartTime] = useState<Date | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
initializeVoice();
|
|
||||||
return () => {
|
|
||||||
Voice.destroy();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const initializeVoice = async () => {
|
|
||||||
await checkPermission();
|
|
||||||
|
|
||||||
Voice.onSpeechStart = onSpeechStart;
|
|
||||||
Voice.onSpeechEnd = onSpeechEnd;
|
|
||||||
Voice.onSpeechResults = onSpeechResults;
|
|
||||||
Voice.onSpeechError = onSpeechError;
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkPermission = async () => {
|
|
||||||
const permission = Platform.OS === 'ios'
|
|
||||||
? PERMISSIONS.IOS.MICROPHONE
|
|
||||||
: PERMISSIONS.ANDROID.RECORD_AUDIO;
|
|
||||||
|
|
||||||
const result = await request(permission);
|
|
||||||
setHasPermission(result === RESULTS.GRANTED);
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestPermission = async (): Promise<boolean> => {
|
|
||||||
const permission = Platform.OS === 'ios'
|
|
||||||
? PERMISSIONS.IOS.MICROPHONE
|
|
||||||
: PERMISSIONS.ANDROID.RECORD_AUDIO;
|
|
||||||
|
|
||||||
const result = await request(permission);
|
|
||||||
const granted = result === RESULTS.GRANTED;
|
|
||||||
setHasPermission(granted);
|
|
||||||
return granted;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSpeechStart = () => {
|
|
||||||
setIsRecording(true);
|
|
||||||
setRecordingStartTime(new Date());
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSpeechEnd = () => {
|
|
||||||
setIsRecording(false);
|
|
||||||
setRecordingStartTime(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSpeechResults = (e: any) => {
|
|
||||||
// Handle speech recognition results
|
|
||||||
console.log('Speech results:', e.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSpeechError = (e: any) => {
|
|
||||||
console.error('Speech recognition error:', e);
|
|
||||||
setIsRecording(false);
|
|
||||||
setRecordingStartTime(null);
|
|
||||||
Alert.alert('Recording Error', 'Failed to process voice recording');
|
|
||||||
};
|
|
||||||
|
|
||||||
const startRecording = async () => {
|
|
||||||
if (!hasPermission) {
|
|
||||||
const granted = await requestPermission();
|
|
||||||
if (!granted) {
|
|
||||||
Alert.alert('Permission Required', 'Microphone access is required for voice recording');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsProcessing(true);
|
|
||||||
|
|
||||||
// Start speech recognition
|
|
||||||
await Voice.start('en-US');
|
|
||||||
|
|
||||||
// For actual audio recording, you would integrate with a library like react-native-audio-recorder-player
|
|
||||||
// This is a placeholder for the recording functionality
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error starting recording:', error);
|
|
||||||
Alert.alert('Error', 'Failed to start recording');
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopRecording = async (): Promise<VoiceRecording | null> => {
|
|
||||||
if (!isRecording) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsProcessing(true);
|
|
||||||
|
|
||||||
// Stop speech recognition
|
|
||||||
await Voice.stop();
|
|
||||||
|
|
||||||
// Calculate duration
|
|
||||||
const duration = recordingStartTime
|
|
||||||
? Math.floor((new Date().getTime() - recordingStartTime.getTime()) / 1000)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// Create recording object (placeholder - actual implementation would save audio file)
|
|
||||||
const recording: VoiceRecording = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
path: `recording-${Date.now()}.m4a`,
|
|
||||||
duration,
|
|
||||||
createdAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
setRecordings(prev => [...prev, recording]);
|
|
||||||
setIsProcessing(false);
|
|
||||||
|
|
||||||
return recording;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error stopping recording:', error);
|
|
||||||
setIsProcessing(false);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const transcribeRecording = async (recordingPath: string): Promise<string | null> => {
|
|
||||||
try {
|
|
||||||
setIsProcessing(true);
|
|
||||||
|
|
||||||
// Start speech recognition for transcription
|
|
||||||
await Voice.start('en-US');
|
|
||||||
|
|
||||||
// This would integrate with a speech-to-text service
|
|
||||||
// For now, return a placeholder
|
|
||||||
const transcript = "Transcribed text from audio recording";
|
|
||||||
|
|
||||||
await Voice.stop();
|
|
||||||
setIsProcessing(false);
|
|
||||||
|
|
||||||
return transcript;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error transcribing recording:', error);
|
|
||||||
setIsProcessing(false);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteRecording = (id: string) => {
|
|
||||||
setRecordings(prev => prev.filter(rec => rec.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const value: VoiceContextType = {
|
|
||||||
isRecording,
|
|
||||||
isProcessing,
|
|
||||||
hasPermission,
|
|
||||||
recordings,
|
|
||||||
requestPermission,
|
|
||||||
startRecording,
|
|
||||||
stopRecording,
|
|
||||||
transcribeRecording,
|
|
||||||
deleteRecording,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VoiceContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</VoiceContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useVoice = (): VoiceContextType => {
|
|
||||||
const context = useContext(VoiceContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useVoice must be used within a VoiceProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
|
||||||
import { ApiResponse, User, Bookmark, Task, Note, TimeEntry, CalendarEvent, SearchFilters, SavedSearch } from '../types';
|
|
||||||
import { getStoredAuthData } from '../utils/storage';
|
|
||||||
|
|
||||||
let API_BASE_URL = __DEV__
|
|
||||||
? 'http://localhost:8080/api'
|
|
||||||
: 'https://trackeep.app/api';
|
|
||||||
|
|
||||||
class APIClient {
|
|
||||||
private client: AxiosInstance;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.client = axios.create({
|
|
||||||
baseURL: API_BASE_URL,
|
|
||||||
timeout: 10000,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.setupInterceptors();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateBaseURL(newBaseURL: string) {
|
|
||||||
API_BASE_URL = newBaseURL;
|
|
||||||
this.client.defaults.baseURL = newBaseURL;
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupInterceptors() {
|
|
||||||
this.client.interceptors.request.use(
|
|
||||||
async (config) => {
|
|
||||||
const authData = await getStoredAuthData();
|
|
||||||
if (authData && authData.token) {
|
|
||||||
config.headers.Authorization = `Bearer ${authData.token}`;
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
this.client.interceptors.response.use(
|
|
||||||
(response) => response,
|
|
||||||
async (error) => {
|
|
||||||
if (error.response?.status === 401) {
|
|
||||||
await this.handleUnauthorized();
|
|
||||||
}
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleUnauthorized() {
|
|
||||||
try {
|
|
||||||
const { clearAuthData } = await import('../utils/storage');
|
|
||||||
await clearAuthData();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error handling unauthorized:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async request<T>(config: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
|
||||||
try {
|
|
||||||
const response = await this.client.request(config);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: response.data,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.response?.data?.message || error.message || 'Unknown error',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiClient = new APIClient();
|
|
||||||
|
|
||||||
export const updateAPIBaseURL = (newBaseURL: string) => {
|
|
||||||
apiClient.updateBaseURL(newBaseURL);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const authAPI = {
|
|
||||||
login: async (email: string, password: string): Promise<ApiResponse<{ token: string; user: User }>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/auth/login',
|
|
||||||
data: { email, password },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
loginWithGitHub: async (): Promise<ApiResponse<{ token: string; user: User }>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/auth/github',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
getCurrentUser: async (token: string): Promise<ApiResponse<User>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/auth/me',
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
updateUser: async (userId: string, updates: Partial<User>): Promise<ApiResponse<User>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'PUT',
|
|
||||||
url: `/users/${userId}`,
|
|
||||||
data: updates,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const bookmarksAPI = {
|
|
||||||
getBookmarks: async (filters?: Partial<SearchFilters>): Promise<ApiResponse<Bookmark[]>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/bookmarks',
|
|
||||||
params: filters,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
createBookmark: async (bookmark: Omit<Bookmark, 'id' | 'createdAt' | 'updatedAt'>): Promise<ApiResponse<Bookmark>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/bookmarks',
|
|
||||||
data: bookmark,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
updateBookmark: async (id: string, updates: Partial<Bookmark>): Promise<ApiResponse<Bookmark>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'PUT',
|
|
||||||
url: `/bookmarks/${id}`,
|
|
||||||
data: updates,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteBookmark: async (id: string): Promise<ApiResponse<void>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/bookmarks/${id}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const tasksAPI = {
|
|
||||||
getTasks: async (filters?: Partial<SearchFilters>): Promise<ApiResponse<Task[]>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/tasks',
|
|
||||||
params: filters,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
createTask: async (task: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Promise<ApiResponse<Task>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/tasks',
|
|
||||||
data: task,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
updateTask: async (id: string, updates: Partial<Task>): Promise<ApiResponse<Task>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'PUT',
|
|
||||||
url: `/tasks/${id}`,
|
|
||||||
data: updates,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteTask: async (id: string): Promise<ApiResponse<void>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/tasks/${id}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const notesAPI = {
|
|
||||||
getNotes: async (filters?: Partial<SearchFilters>): Promise<ApiResponse<Note[]>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/notes',
|
|
||||||
params: filters,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
createNote: async (note: Omit<Note, 'id' | 'createdAt' | 'updatedAt'>): Promise<ApiResponse<Note>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/notes',
|
|
||||||
data: note,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
updateNote: async (id: string, updates: Partial<Note>): Promise<ApiResponse<Note>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'PUT',
|
|
||||||
url: `/notes/${id}`,
|
|
||||||
data: updates,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteNote: async (id: string): Promise<ApiResponse<void>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/notes/${id}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const timeEntriesAPI = {
|
|
||||||
getTimeEntries: async (filters?: any): Promise<ApiResponse<TimeEntry[]>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/time-entries',
|
|
||||||
params: filters,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
createTimeEntry: async (entry: Omit<TimeEntry, 'id' | 'createdAt'>): Promise<ApiResponse<TimeEntry>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/time-entries',
|
|
||||||
data: entry,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
updateTimeEntry: async (id: string, updates: Partial<TimeEntry>): Promise<ApiResponse<TimeEntry>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'PUT',
|
|
||||||
url: `/time-entries/${id}`,
|
|
||||||
data: updates,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteTimeEntry: async (id: string): Promise<ApiResponse<void>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/time-entries/${id}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const searchAPI = {
|
|
||||||
search: async (filters: SearchFilters): Promise<ApiResponse<any>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/search',
|
|
||||||
data: filters,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
getSavedSearches: async (): Promise<ApiResponse<SavedSearch[]>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/search/saved',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
createSavedSearch: async (search: Omit<SavedSearch, 'id' | 'createdAt' | 'updatedAt'>): Promise<ApiResponse<SavedSearch>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/search/saved',
|
|
||||||
data: search,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
updateSavedSearch: async (id: string, updates: Partial<SavedSearch>): Promise<ApiResponse<SavedSearch>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'PUT',
|
|
||||||
url: `/search/saved/${id}`,
|
|
||||||
data: updates,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteSavedSearch: async (id: string): Promise<ApiResponse<void>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/search/saved/${id}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const calendarAPI = {
|
|
||||||
getEvents: async (filters?: any): Promise<ApiResponse<CalendarEvent[]>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/calendar/events',
|
|
||||||
params: filters,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
createEvent: async (event: Omit<CalendarEvent, 'id'>): Promise<ApiResponse<CalendarEvent>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/calendar/events',
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
updateEvent: async (id: string, updates: Partial<CalendarEvent>): Promise<ApiResponse<CalendarEvent>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'PUT',
|
|
||||||
url: `/calendar/events/${id}`,
|
|
||||||
data: updates,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteEvent: async (id: string): Promise<ApiResponse<void>> => {
|
|
||||||
return apiClient.request({
|
|
||||||
method: 'DELETE',
|
|
||||||
url: `/calendar/events/${id}`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
export interface User {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
avatar?: string;
|
|
||||||
githubUsername?: string;
|
|
||||||
preferences: UserPreferences;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserPreferences {
|
|
||||||
theme: 'light' | 'dark' | 'auto';
|
|
||||||
notifications: boolean;
|
|
||||||
syncEnabled: boolean;
|
|
||||||
language: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Bookmark {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
description?: string;
|
|
||||||
tags: string[];
|
|
||||||
isFavorite: boolean;
|
|
||||||
isRead: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
content?: string;
|
|
||||||
thumbnail?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Task {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
status: 'todo' | 'in_progress' | 'completed' | 'cancelled';
|
|
||||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
|
||||||
dueDate?: Date;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
tags: string[];
|
|
||||||
estimatedTime?: number;
|
|
||||||
actualTime?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Note {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
tags: string[];
|
|
||||||
isPublic: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
parentId?: string;
|
|
||||||
children?: Note[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TimeEntry {
|
|
||||||
id: string;
|
|
||||||
taskId?: string;
|
|
||||||
bookmarkId?: string;
|
|
||||||
noteId?: string;
|
|
||||||
startTime: Date;
|
|
||||||
endTime?: Date;
|
|
||||||
duration?: number;
|
|
||||||
description: string;
|
|
||||||
tags: string[];
|
|
||||||
billable: boolean;
|
|
||||||
hourlyRate?: number;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CalendarEvent {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
startTime: Date;
|
|
||||||
endTime: Date;
|
|
||||||
type: 'task' | 'meeting' | 'deadline' | 'reminder' | 'habit';
|
|
||||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
|
||||||
location?: string;
|
|
||||||
attendees?: string[];
|
|
||||||
recurring?: RecurrenceRule;
|
|
||||||
source: 'trackeep' | 'google' | 'outlook' | 'manual';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecurrenceRule {
|
|
||||||
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
|
|
||||||
interval: number;
|
|
||||||
endDate?: Date;
|
|
||||||
daysOfWeek?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchFilters {
|
|
||||||
query: string;
|
|
||||||
contentType: 'all' | 'bookmarks' | 'tasks' | 'notes' | 'files';
|
|
||||||
tags: string[];
|
|
||||||
dateRange: { start: Date; end: Date };
|
|
||||||
author: string;
|
|
||||||
language: string;
|
|
||||||
fileTypes: string[];
|
|
||||||
isFavorite: boolean;
|
|
||||||
isRead: boolean;
|
|
||||||
searchMode: 'fulltext' | 'semantic' | 'hybrid';
|
|
||||||
threshold: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SavedSearch {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
query: string;
|
|
||||||
filters: SearchFilters;
|
|
||||||
alert: boolean;
|
|
||||||
lastRun?: Date;
|
|
||||||
runCount: number;
|
|
||||||
isPublic: boolean;
|
|
||||||
description?: string;
|
|
||||||
tags: string[];
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiResponse<T> {
|
|
||||||
success: boolean;
|
|
||||||
data?: T;
|
|
||||||
error?: string;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NavigationState {
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
user?: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OfflineState {
|
|
||||||
isOnline: boolean;
|
|
||||||
syncInProgress: boolean;
|
|
||||||
pendingChanges: number;
|
|
||||||
lastSyncTime?: Date;
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
declare module 'react-native-push-notification' {
|
|
||||||
export interface PushNotificationPermissions {
|
|
||||||
alert?: boolean;
|
|
||||||
badge?: boolean;
|
|
||||||
sound?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PushNotification {
|
|
||||||
configure(options: {
|
|
||||||
onRegister?: (token: any) => void;
|
|
||||||
onNotification?: (notification: any) => void;
|
|
||||||
permissions?: PushNotificationPermissions;
|
|
||||||
popInitialNotification?: boolean;
|
|
||||||
requestPermissions?: boolean;
|
|
||||||
}): void;
|
|
||||||
|
|
||||||
requestPermissions(callback?: (permissions: PushNotificationPermissions) => void): void;
|
|
||||||
checkPermissions(callback?: (permissions: PushNotificationPermissions) => void): void;
|
|
||||||
|
|
||||||
localNotification(details: {
|
|
||||||
channelId?: string;
|
|
||||||
id?: number;
|
|
||||||
title?: string;
|
|
||||||
message?: string;
|
|
||||||
userInfo?: any;
|
|
||||||
actions?: string[];
|
|
||||||
}): void;
|
|
||||||
|
|
||||||
localNotificationSchedule(details: {
|
|
||||||
channelId?: string;
|
|
||||||
id?: number;
|
|
||||||
title?: string;
|
|
||||||
message?: string;
|
|
||||||
date: Date;
|
|
||||||
userInfo?: any;
|
|
||||||
actions?: string[];
|
|
||||||
allowWhileIdle?: boolean;
|
|
||||||
}): void;
|
|
||||||
|
|
||||||
cancelLocalNotifications(details: { id: string }): void;
|
|
||||||
cancelAllLocalNotifications(): void;
|
|
||||||
|
|
||||||
createChannel(channelId: string, channelName: string, importance: number, callback?: (created: any) => void): void;
|
|
||||||
createChannelImportance(channelId: string, channelName: string, importance: number, callback?: (created: any) => void): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PushNotification: PushNotification;
|
|
||||||
export default PushNotification;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
declare module 'react-native-voice' {
|
|
||||||
export interface VoiceResults {
|
|
||||||
value?: string[];
|
|
||||||
error?: boolean;
|
|
||||||
isFinal?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class Voice {
|
|
||||||
static isAvailable(): Promise<boolean>;
|
|
||||||
static start(locale?: string): Promise<void>;
|
|
||||||
static stop(): Promise<void>;
|
|
||||||
static destroy(): Promise<void>;
|
|
||||||
static onSpeechStart?: (e: any) => void;
|
|
||||||
static onSpeechEnd?: (e: any) => void;
|
|
||||||
static onSpeechResults?: (e: VoiceResults) => void;
|
|
||||||
static onSpeechError?: (e: any) => void;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { useNotifications } from '../services/NotificationContext';
|
|
||||||
|
|
||||||
export class NotificationUtils {
|
|
||||||
private static notifications = useNotifications();
|
|
||||||
|
|
||||||
static scheduleTaskReminder(taskId: string, taskTitle: string, dueDate: Date) {
|
|
||||||
const reminderTime = new Date(dueDate.getTime() - 24 * 60 * 60 * 1000); // 1 day before
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
if (reminderTime > now) {
|
|
||||||
this.notifications.scheduleNotification({
|
|
||||||
id: `task-reminder-${taskId}`,
|
|
||||||
title: 'Task Due Soon',
|
|
||||||
message: `Task "${taskTitle}" is due tomorrow`,
|
|
||||||
date: reminderTime,
|
|
||||||
userInfo: { type: 'task', taskId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule final reminder 1 hour before
|
|
||||||
const finalReminder = new Date(dueDate.getTime() - 60 * 60 * 1000);
|
|
||||||
if (finalReminder > now) {
|
|
||||||
this.notifications.scheduleNotification({
|
|
||||||
id: `task-final-${taskId}`,
|
|
||||||
title: 'Task Due Soon',
|
|
||||||
message: `Task "${taskTitle}" is due in 1 hour`,
|
|
||||||
date: finalReminder,
|
|
||||||
userInfo: { type: 'task', taskId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static scheduleDeadlineReminder(taskId: string, taskTitle: string, deadline: Date) {
|
|
||||||
const reminderTimes = [
|
|
||||||
{ days: 7, message: 'due in 1 week' },
|
|
||||||
{ days: 3, message: 'due in 3 days' },
|
|
||||||
{ days: 1, message: 'due tomorrow' },
|
|
||||||
{ hours: 1, message: 'due in 1 hour' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
reminderTimes.forEach((reminder, index) => {
|
|
||||||
let reminderTime: Date;
|
|
||||||
|
|
||||||
if (reminder.days) {
|
|
||||||
reminderTime = new Date(deadline.getTime() - reminder.days * 24 * 60 * 60 * 1000);
|
|
||||||
} else if (reminder.hours) {
|
|
||||||
reminderTime = new Date(deadline.getTime() - reminder.hours * 60 * 60 * 1000);
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reminderTime > now) {
|
|
||||||
this.notifications.scheduleNotification({
|
|
||||||
id: `deadline-${taskId}-${index}`,
|
|
||||||
title: 'Deadline Reminder',
|
|
||||||
message: `Task "${taskTitle}" ${reminder.message}`,
|
|
||||||
date: reminderTime,
|
|
||||||
userInfo: { type: 'deadline', taskId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static scheduleStudyReminder(courseId: string, courseTitle: string, studyTime: Date) {
|
|
||||||
this.notifications.scheduleNotification({
|
|
||||||
id: `study-${courseId}`,
|
|
||||||
title: 'Study Reminder',
|
|
||||||
message: `Time to study "${courseTitle}"`,
|
|
||||||
date: studyTime,
|
|
||||||
userInfo: { type: 'study', courseId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
static cancelTaskNotifications(taskId: string) {
|
|
||||||
this.notifications.cancelNotification(`task-reminder-${taskId}`);
|
|
||||||
this.notifications.cancelNotification(`task-final-${taskId}`);
|
|
||||||
|
|
||||||
// Cancel deadline notifications
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
this.notifications.cancelNotification(`deadline-${taskId}-${i}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static showTaskCompletedNotification(taskTitle: string) {
|
|
||||||
this.notifications.showLocalNotification(
|
|
||||||
'Task Completed! 🎉',
|
|
||||||
`Great job! You completed "${taskTitle}"`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static showTimeTrackingReminder() {
|
|
||||||
this.notifications.showLocalNotification(
|
|
||||||
'Time Tracking Reminder',
|
|
||||||
'Don\'t forget to track your time on current tasks'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static showDailySummaryNotification(completedTasks: number, totalHours: number) {
|
|
||||||
this.notifications.showLocalNotification(
|
|
||||||
'Daily Summary 📊',
|
|
||||||
`Completed ${completedTasks} tasks, tracked ${totalHours.toFixed(1)} hours today`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { getOfflineData, clearOfflineChanges, addOfflineChange } from './storage';
|
|
||||||
import { bookmarksAPI, tasksAPI, notesAPI, timeEntriesAPI } from '../services/api';
|
|
||||||
|
|
||||||
interface OfflineChange {
|
|
||||||
id: string;
|
|
||||||
type: 'create' | 'update' | 'delete';
|
|
||||||
entityType: 'bookmark' | 'task' | 'note' | 'timeEntry';
|
|
||||||
data: any;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getPendingChangesCount = async (): Promise<number> => {
|
|
||||||
try {
|
|
||||||
const changes = await getOfflineData('OFFLINE_CHANGES') as OfflineChange[];
|
|
||||||
return changes.length;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting pending changes count:', error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const syncOfflineData = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const changes = await getOfflineData('OFFLINE_CHANGES') as OfflineChange[];
|
|
||||||
|
|
||||||
for (const change of changes) {
|
|
||||||
try {
|
|
||||||
await processChange(change);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error processing change ${change.id}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await clearOfflineChanges();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Sync error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const processChange = async (change: OfflineChange): Promise<void> => {
|
|
||||||
switch (change.entityType) {
|
|
||||||
case 'bookmark':
|
|
||||||
await processBookmarkChange(change);
|
|
||||||
break;
|
|
||||||
case 'task':
|
|
||||||
await processTaskChange(change);
|
|
||||||
break;
|
|
||||||
case 'note':
|
|
||||||
await processNoteChange(change);
|
|
||||||
break;
|
|
||||||
case 'timeEntry':
|
|
||||||
await processTimeEntryChange(change);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.warn(`Unknown entity type: ${change.entityType}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const processBookmarkChange = async (change: OfflineChange): Promise<void> => {
|
|
||||||
switch (change.type) {
|
|
||||||
case 'create':
|
|
||||||
await bookmarksAPI.createBookmark(change.data);
|
|
||||||
break;
|
|
||||||
case 'update':
|
|
||||||
await bookmarksAPI.updateBookmark(change.data.id, change.data);
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
await bookmarksAPI.deleteBookmark(change.data.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const processTaskChange = async (change: OfflineChange): Promise<void> => {
|
|
||||||
switch (change.type) {
|
|
||||||
case 'create':
|
|
||||||
await tasksAPI.createTask(change.data);
|
|
||||||
break;
|
|
||||||
case 'update':
|
|
||||||
await tasksAPI.updateTask(change.data.id, change.data);
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
await tasksAPI.deleteTask(change.data.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const processNoteChange = async (change: OfflineChange): Promise<void> => {
|
|
||||||
switch (change.type) {
|
|
||||||
case 'create':
|
|
||||||
await notesAPI.createNote(change.data);
|
|
||||||
break;
|
|
||||||
case 'update':
|
|
||||||
await notesAPI.updateNote(change.data.id, change.data);
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
await notesAPI.deleteNote(change.data.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const processTimeEntryChange = async (change: OfflineChange): Promise<void> => {
|
|
||||||
switch (change.type) {
|
|
||||||
case 'create':
|
|
||||||
await timeEntriesAPI.createTimeEntry(change.data);
|
|
||||||
break;
|
|
||||||
case 'update':
|
|
||||||
await timeEntriesAPI.updateTimeEntry(change.data.id, change.data);
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
await timeEntriesAPI.deleteTimeEntry(change.data.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const queueOfflineChange = async (
|
|
||||||
type: 'create' | 'update' | 'delete',
|
|
||||||
entityType: 'bookmark' | 'task' | 'note' | 'timeEntry',
|
|
||||||
data: any
|
|
||||||
): Promise<void> => {
|
|
||||||
await addOfflineChange({
|
|
||||||
type,
|
|
||||||
entityType,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import { User } from '../types';
|
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
|
||||||
AUTH_TOKEN: '@trackeep_auth_token',
|
|
||||||
USER_DATA: '@trackeep_user_data',
|
|
||||||
THEME: '@trackeep_theme',
|
|
||||||
BOOKMARKS: '@trackeep_bookmarks',
|
|
||||||
TASKS: '@trackeep_tasks',
|
|
||||||
NOTES: '@trackeep_notes',
|
|
||||||
TIME_ENTRIES: '@trackeep_time_entries',
|
|
||||||
OFFLINE_CHANGES: '@trackeep_offline_changes',
|
|
||||||
SEARCH_HISTORY: '@trackeep_search_history',
|
|
||||||
SAVED_SEARCHES: '@trackeep_saved_searches',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export interface StoredAuthData {
|
|
||||||
token: string;
|
|
||||||
user: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const storeAuthData = async (data: StoredAuthData): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.multiSet([
|
|
||||||
[STORAGE_KEYS.AUTH_TOKEN, data.token],
|
|
||||||
[STORAGE_KEYS.USER_DATA, JSON.stringify(data.user)],
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error storing auth data:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getStoredAuthData = async (): Promise<StoredAuthData | null> => {
|
|
||||||
try {
|
|
||||||
const [token, userData] = await AsyncStorage.multiGet([
|
|
||||||
STORAGE_KEYS.AUTH_TOKEN,
|
|
||||||
STORAGE_KEYS.USER_DATA,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (token[1] && userData[1]) {
|
|
||||||
return {
|
|
||||||
token: token[1],
|
|
||||||
user: JSON.parse(userData[1]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting stored auth data:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearAuthData = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.multiRemove([
|
|
||||||
STORAGE_KEYS.AUTH_TOKEN,
|
|
||||||
STORAGE_KEYS.USER_DATA,
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error clearing auth data:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const loadTheme = async (): Promise<'light' | 'dark'> => {
|
|
||||||
try {
|
|
||||||
const theme = await AsyncStorage.getItem(STORAGE_KEYS.THEME);
|
|
||||||
return theme === 'dark' ? 'dark' : 'light';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading theme:', error);
|
|
||||||
return 'light';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const saveTheme = async (theme: 'light' | 'dark'): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.setItem(STORAGE_KEYS.THEME, theme);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving theme:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const storeOfflineData = async <T>(key: keyof typeof STORAGE_KEYS, data: T[]): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.setItem(STORAGE_KEYS[key], JSON.stringify(data));
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error storing offline data for ${key}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getOfflineData = async <T>(key: keyof typeof STORAGE_KEYS): Promise<T[]> => {
|
|
||||||
try {
|
|
||||||
const data = await AsyncStorage.getItem(STORAGE_KEYS[key]);
|
|
||||||
return data ? JSON.parse(data) : [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error getting offline data for ${key}:`, error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const addOfflineChange = async (change: any): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const existingChanges = await getOfflineData('OFFLINE_CHANGES');
|
|
||||||
existingChanges.push({
|
|
||||||
...change,
|
|
||||||
id: Date.now().toString(),
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
await storeOfflineData('OFFLINE_CHANGES', existingChanges);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error adding offline change:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearOfflineChanges = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.removeItem(STORAGE_KEYS.OFFLINE_CHANGES);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error clearing offline changes:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPendingChangesCount = async (): Promise<number> => {
|
|
||||||
try {
|
|
||||||
const changes = await getOfflineData('OFFLINE_CHANGES');
|
|
||||||
return changes.length;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting pending changes count:', error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const storeSearchHistory = async (query: string): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const history = await getOfflineData('SEARCH_HISTORY');
|
|
||||||
const filteredHistory = (history as string[]).filter((item: string) => item !== query);
|
|
||||||
filteredHistory.unshift(query);
|
|
||||||
const limitedHistory = filteredHistory.slice(0, 50);
|
|
||||||
await storeOfflineData('SEARCH_HISTORY', limitedHistory);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error storing search history:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSearchHistory = async (): Promise<string[]> => {
|
|
||||||
try {
|
|
||||||
return await getOfflineData('SEARCH_HISTORY');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting search history:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearAllData = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await AsyncStorage.multiRemove(Object.values(STORAGE_KEYS));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error clearing all data:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@tsconfig/react-native/tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"allowJs": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"lib": ["es2017", "es2018", "es2019"],
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"noEmit": true,
|
|
||||||
"strict": true,
|
|
||||||
"target": "esnext",
|
|
||||||
"baseUrl": "./src",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["*"]
|
|
||||||
},
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"resolveJsonModule": true
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src/**/*",
|
|
||||||
"index.js",
|
|
||||||
"App.tsx"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules",
|
|
||||||
"babel.config.js",
|
|
||||||
"metro.config.js",
|
|
||||||
"jest.config.js"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# OAuth Service Configuration
|
|
||||||
OAUTH_SERVICE_PORT=9090
|
|
||||||
OAUTH_GIN_MODE=debug
|
|
||||||
OAUTH_CORS_ALLOWED_ORIGINS=*
|
|
||||||
|
|
||||||
# GitHub OAuth Configuration
|
|
||||||
GITHUB_CLIENT_ID=your_github_client_id
|
|
||||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
|
||||||
GITHUB_REDIRECT_URL=http://localhost:9090/auth/github/callback
|
|
||||||
|
|
||||||
# Production URLs (update these for your deployment)
|
|
||||||
DEFAULT_CLIENT_URL=https://yourdomain.com
|
|
||||||
SERVICE_DOMAIN=https://oauth.yourdomain.com
|
|
||||||
|
|
||||||
# JWT Configuration for OAuth Service
|
|
||||||
OAUTH_JWT_SECRET=your_oauth_jwt_secret_here
|
|
||||||
OAUTH_JWT_EXPIRES_IN=24h
|
|
||||||
|
|
||||||
# Database Configuration (if using separate database for OAuth)
|
|
||||||
OAUTH_DB_TYPE=postgres
|
|
||||||
OAUTH_DB_HOST=localhost
|
|
||||||
OAUTH_DB_PORT=5432
|
|
||||||
OAUTH_DB_USER=oauth_user
|
|
||||||
OAUTH_DB_PASSWORD=your_oauth_password
|
|
||||||
OAUTH_DB_NAME=oauth_db
|
|
||||||
OAUTH_DB_SSL_MODE=disable
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# OAuth Service Configuration Changes
|
|
||||||
|
|
||||||
## Summary of Changes
|
|
||||||
|
|
||||||
### 1. CORS Configuration Updated
|
|
||||||
- **Before**: Restricted to specific origins (`http://localhost:5173,http://localhost:8080`)
|
|
||||||
- **After**: Allows all origins (`*`) for maximum flexibility
|
|
||||||
- **Implementation**: Updated CORS middleware to handle wildcard origins properly
|
|
||||||
|
|
||||||
### 2. Dynamic Client URL Detection
|
|
||||||
- **Before**: Hardcoded default client URL (`http://localhost:5173`)
|
|
||||||
- **After**: Dynamically determines client URL from:
|
|
||||||
- Query parameter `redirect_uri` (highest priority)
|
|
||||||
- Request `Origin` header
|
|
||||||
- Request `Referer` header
|
|
||||||
- Fallback to `DEFAULT_CLIENT_URL` environment variable
|
|
||||||
- **Implementation**: Enhanced `initiateGitHubOAuth` function with URL parsing logic
|
|
||||||
|
|
||||||
### 3. Service Domain Configuration
|
|
||||||
- **Added**: New `SERVICE_DOMAIN` environment variable
|
|
||||||
- **Purpose**: Identifies the OAuth service domain in logs and webhook responses
|
|
||||||
- **Current Value**: `https://oauth.tdvorak.dev`
|
|
||||||
|
|
||||||
### 4. Enhanced Webhook Handling
|
|
||||||
- **Before**: Basic webhook processing with minimal logging
|
|
||||||
- **After**:
|
|
||||||
- Proper webhook secret configuration check
|
|
||||||
- Enhanced logging with service domain identification
|
|
||||||
- Detailed event type handling with better payload logging
|
|
||||||
- Response includes service domain information
|
|
||||||
|
|
||||||
### 5. Environment Files Updated
|
|
||||||
- **`.env`**: Updated with new configuration values
|
|
||||||
- **`.env.example`**: Updated to reflect the new structure for other deployments
|
|
||||||
|
|
||||||
## Key Benefits
|
|
||||||
|
|
||||||
1. **Multi-domain Support**: Service can now handle requests from any domain
|
|
||||||
2. **Dynamic Client Detection**: Automatically redirects users back to their originating domain
|
|
||||||
3. **Better Debugging**: Enhanced logging makes troubleshooting easier
|
|
||||||
4. **Production Ready**: Configuration is more flexible for different deployment scenarios
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- While CORS is set to allow all origins, the OAuth flow itself remains secure
|
|
||||||
- State parameter validation prevents CSRF attacks
|
|
||||||
- JWT tokens are still properly validated
|
|
||||||
- Webhook signature validation is in place (though secret needs to be configured)
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
The service will now:
|
|
||||||
1. Accept OAuth requests from any domain
|
|
||||||
2. Automatically detect the client's origin for proper redirects
|
|
||||||
3. Handle webhooks with better logging and domain identification
|
|
||||||
4. Work seamlessly with the user's domain (`tdvorak.dev`) and any other domains
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
FROM golang:1.21-alpine AS builder
|
|
||||||
|
|
||||||
# Set the working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy go mod files
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
|
|
||||||
# Download dependencies
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# Copy the source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the binary
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o oauth-service main.go
|
|
||||||
|
|
||||||
# Final stage
|
|
||||||
FROM alpine:latest
|
|
||||||
|
|
||||||
# Install ca-certificates for HTTPS requests
|
|
||||||
RUN apk --no-cache add ca-certificates
|
|
||||||
|
|
||||||
# Create a non-root user
|
|
||||||
RUN addgroup -g 1001 -S oauth && \
|
|
||||||
adduser -u 1001 -S oauth -G oauth
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy the binary from builder stage
|
|
||||||
COPY --from=builder /app/oauth-service .
|
|
||||||
|
|
||||||
# Copy .env file if it exists
|
|
||||||
COPY --from=builder /app/.env.example .env
|
|
||||||
|
|
||||||
# Change ownership to non-root user
|
|
||||||
RUN chown -R oauth:oauth /app
|
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER oauth
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 9090
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:9090/health || exit 1
|
|
||||||
|
|
||||||
# Run the binary
|
|
||||||
CMD ["./oauth-service"]
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# TSX Integration Fixes Summary
|
|
||||||
|
|
||||||
## ✅ All Errors Fixed Successfully
|
|
||||||
|
|
||||||
### **TypeScript Configuration Fixed:**
|
|
||||||
- ✅ Removed problematic `solid-js/env` type from tsconfig.json
|
|
||||||
- ✅ Fixed all event handler type annotations
|
|
||||||
- ✅ Resolved null safety issues with event.currentTarget
|
|
||||||
|
|
||||||
### **Event Handler Fixes:**
|
|
||||||
- ✅ Added proper `MouseEvent` typing for onClick handlers
|
|
||||||
- ✅ Fixed HTMLElement casting for DOM queries
|
|
||||||
- ✅ Added null safety checks with optional chaining
|
|
||||||
|
|
||||||
### **Build System Fixed:**
|
|
||||||
- ✅ Renamed `.js` config files to `.cjs` for ES module compatibility
|
|
||||||
- ✅ Fixed PostCSS and TailwindCSS configuration
|
|
||||||
- ✅ All builds now pass without errors
|
|
||||||
|
|
||||||
### **Component Structure:**
|
|
||||||
- ✅ All TSX components properly typed with TypeScript
|
|
||||||
- ✅ SolidJS reactive signals working correctly
|
|
||||||
- ✅ Event handlers properly typed and functional
|
|
||||||
|
|
||||||
## 🚀 Final Status
|
|
||||||
|
|
||||||
**✅ TypeScript Check:** `npx tsc --noEmit` - No errors
|
|
||||||
**✅ Build:** `npm run build` - Successful
|
|
||||||
**✅ Dev Server:** `npm run dev` - Working
|
|
||||||
**✅ Backend:** `go run main.go` - Running successfully
|
|
||||||
**✅ Integration:** Full-stack system operational
|
|
||||||
|
|
||||||
## 📁 Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
oauth-service/
|
|
||||||
├── src/
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── Dashboard.tsx ✅ Fixed
|
|
||||||
│ │ ├── CourseManagement.tsx ✅ Fixed
|
|
||||||
│ │ └── InstanceManagement.tsx ✅ Fixed
|
|
||||||
│ ├── App.tsx ✅ Working
|
|
||||||
│ ├── index.tsx ✅ Working
|
|
||||||
│ └── styles.css ✅ Working
|
|
||||||
├── static/ ✅ Built frontend
|
|
||||||
├── main.go ✅ Backend running
|
|
||||||
├── tsconfig.json ✅ Fixed config
|
|
||||||
├── package.json ✅ Dependencies installed
|
|
||||||
└── dev.sh ✅ Development script
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Ready to Use
|
|
||||||
|
|
||||||
**Development:**
|
|
||||||
```bash
|
|
||||||
./dev.sh # Starts both frontend (5174) and backend (9090)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Production:**
|
|
||||||
```bash
|
|
||||||
npm run build && go run main.go
|
|
||||||
```
|
|
||||||
|
|
||||||
**Access:** http://localhost:9090/dashboard
|
|
||||||
|
|
||||||
All TypeScript errors have been resolved and the system is fully functional! 🎉
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
# Centralized OAuth Service
|
|
||||||
|
|
||||||
This is a **standalone OAuth service** that handles GitHub authentication and email verification for all users. Users never need to set up their own OAuth applications - everything is centralized.
|
|
||||||
|
|
||||||
## 🎯 **How It Works**
|
|
||||||
|
|
||||||
### **For Users:**
|
|
||||||
1. **GitHub OAuth**: Click "Connect GitHub" → GitHub authorization → Automatic login with GitHub profile
|
|
||||||
2. **Email Verification**: Enter email → Receive verification code → Verify email for 2FA
|
|
||||||
|
|
||||||
### **For Developers:**
|
|
||||||
1. **Zero setup** - No OAuth app creation needed
|
|
||||||
2. **Simple integration** - Just redirect to our service
|
|
||||||
3. **Secure authentication** - We handle all the complexity
|
|
||||||
4. **User management** - Centralized user database
|
|
||||||
|
|
||||||
## 🚀 **Quick Start**
|
|
||||||
|
|
||||||
### **1. Setup the OAuth Service**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Navigate to the OAuth service
|
|
||||||
cd oauth-service
|
|
||||||
|
|
||||||
# Run the setup script
|
|
||||||
./setup.sh
|
|
||||||
|
|
||||||
# Edit the .env file with your GitHub OAuth credentials
|
|
||||||
nano .env
|
|
||||||
|
|
||||||
# Start the service
|
|
||||||
go run main.go
|
|
||||||
```
|
|
||||||
|
|
||||||
### **2. GitHub OAuth App Setup (One Time)**
|
|
||||||
|
|
||||||
1. Go to GitHub Settings → Developer settings → OAuth Apps
|
|
||||||
2. Create a new OAuth app with:
|
|
||||||
- **Application name**: Trackeep OAuth Service
|
|
||||||
- **Homepage URL**: `http://localhost:9090`
|
|
||||||
- **Authorization callback URL**: `http://localhost:9090/auth/github/callback`
|
|
||||||
3. Copy the Client ID and Client Secret to `.env`
|
|
||||||
|
|
||||||
### **3. Email Verification Setup (One Time)**
|
|
||||||
|
|
||||||
1. Configure smtp.purelymail.com for sending verification emails:
|
|
||||||
- **SMTP Host**: `smtp.purelymail.com`
|
|
||||||
- **SMTP Port**: `587`
|
|
||||||
- **Username**: Your purelymail SMTP username
|
|
||||||
- **Password**: Your purelymail SMTP password
|
|
||||||
2. Add SMTP credentials to `.env` file
|
|
||||||
3. The service will send 6-digit verification codes for 2FA
|
|
||||||
|
|
||||||
### **4. Integration in Your App**
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Redirect to GitHub OAuth
|
|
||||||
const connectGitHub = () => {
|
|
||||||
window.location.href = 'http://localhost:9090/auth/github?redirect_uri=' +
|
|
||||||
encodeURIComponent(window.location.origin);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send email verification code
|
|
||||||
const sendEmailVerification = (email) => {
|
|
||||||
fetch('http://localhost:9090/api/v1/email/send', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email })
|
|
||||||
}).then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.demo_code) {
|
|
||||||
console.log('Demo verification code:', data.demo_code);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Verify email code
|
|
||||||
const verifyEmailCode = (email, code) => {
|
|
||||||
fetch('http://localhost:9090/api/v1/email/verify', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email, code })
|
|
||||||
}).then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.verified) {
|
|
||||||
console.log('Email verified successfully!');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle callback (works for both GitHub and Email)
|
|
||||||
const handleCallback = () => {
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const token = urlParams.get('token');
|
|
||||||
const username = urlParams.get('user');
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
localStorage.setItem('token', token);
|
|
||||||
localStorage.setItem('username', username);
|
|
||||||
// Redirect to dashboard
|
|
||||||
window.location.href = '/app';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📡 **API Endpoints**
|
|
||||||
|
|
||||||
### **OAuth Endpoints:**
|
|
||||||
- `GET /auth/github` - Initiate GitHub OAuth flow
|
|
||||||
- `GET /auth/github/callback` - Handle GitHub callback
|
|
||||||
|
|
||||||
### **Email Verification Endpoints:**
|
|
||||||
- `POST /api/v1/email/send` - Send verification code to email
|
|
||||||
- `POST /api/v1/email/verify` - Verify email code for 2FA
|
|
||||||
|
|
||||||
### **API Endpoints:**
|
|
||||||
- `GET /api/v1/user/me` - Get current user info
|
|
||||||
- `GET /api/v1/user/:username/repos` - Get user repositories
|
|
||||||
- `POST /api/v1/webhook/github` - GitHub webhook handler
|
|
||||||
- `POST /api/v1/email/verify` - Verify email code
|
|
||||||
|
|
||||||
### **Utility:**
|
|
||||||
- `GET /health` - Service health check
|
|
||||||
|
|
||||||
## 🔧 **Configuration**
|
|
||||||
|
|
||||||
### **Environment Variables:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# GitHub OAuth (Admin Only)
|
|
||||||
GITHUB_CLIENT_ID=your_github_client_id
|
|
||||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
|
||||||
GITHUB_REDIRECT_URL=http://localhost:9090/auth/github/callback
|
|
||||||
|
|
||||||
# Email Verification Configuration (Admin Only)
|
|
||||||
SMTP_HOST=smtp.purelymail.com
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USERNAME=your_purelymail_username
|
|
||||||
SMTP_PASSWORD=your_purelymail_password
|
|
||||||
|
|
||||||
# Service Configuration
|
|
||||||
PORT=9090
|
|
||||||
JWT_SECRET=your-super-secret-jwt-key
|
|
||||||
DEFAULT_CLIENT_URL=http://localhost:5173
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🏗️ **Architecture**
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
|
||||||
│ User App │ │ OAuth Service │ │ GitHub │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ Connect GitHub ─┼───>│ /auth/github ────>│ OAuth Flow │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ Handle Callback │<───>│ /auth/callback │<───>│ Return Token │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ Store Token │ │ Generate JWT │ │ │
|
|
||||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔒 **Security Features**
|
|
||||||
|
|
||||||
- **CSRF Protection**: State parameter validation
|
|
||||||
- **Secure JWT**: Signed tokens with expiration
|
|
||||||
- **CORS Support**: Configurable allowed origins
|
|
||||||
- **Webhook Support**: Optional webhook secret validation
|
|
||||||
- **Rate Limiting**: GitHub API rate limit awareness
|
|
||||||
|
|
||||||
## 📊 **User Management**
|
|
||||||
|
|
||||||
The service maintains a centralized user database:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type User struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
GitHubID int `json:"github_id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
AvatarURL string `json:"avatar_url"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
LastLogin time.Time `json:"last_login"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 **Multi-Application Support**
|
|
||||||
|
|
||||||
The same OAuth service can serve multiple applications:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// App 1
|
|
||||||
window.location.href = 'http://localhost:9090/auth/github?redirect_uri=http://app1.com';
|
|
||||||
|
|
||||||
// App 2
|
|
||||||
window.location.href = 'http://localhost:9090/auth/github?redirect_uri=http://app2.com';
|
|
||||||
|
|
||||||
// App 3
|
|
||||||
window.location.href = 'http://localhost:9090/auth/github?redirect_uri=http://app3.com';
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 **Production Deployment**
|
|
||||||
|
|
||||||
### **Docker Deployment:**
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
FROM golang:1.21-alpine AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY . .
|
|
||||||
RUN go mod download && go build -o oauth-service
|
|
||||||
|
|
||||||
FROM alpine:latest
|
|
||||||
RUN apk --no-cache add ca-certificates
|
|
||||||
WORKDIR /root/
|
|
||||||
COPY --from=builder /app/oauth-service .
|
|
||||||
COPY .env .
|
|
||||||
EXPOSE 9090
|
|
||||||
CMD ["./oauth-service"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Docker Compose:**
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: '3.8'
|
|
||||||
services:
|
|
||||||
oauth-service:
|
|
||||||
build: ./oauth-service
|
|
||||||
ports:
|
|
||||||
- "9090:9090"
|
|
||||||
environment:
|
|
||||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
|
||||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
|
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
|
||||||
restart: unless-stopped
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛠️ **Development**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
go mod tidy
|
|
||||||
|
|
||||||
# Run in development
|
|
||||||
go run main.go
|
|
||||||
|
|
||||||
# Build for production
|
|
||||||
go build -o oauth-service main.go
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 **Benefits**
|
|
||||||
|
|
||||||
### **For Users:**
|
|
||||||
- ✅ **Zero configuration** - No OAuth app setup
|
|
||||||
- ✅ **Single sign-on** - One GitHub account for all apps
|
|
||||||
- ✅ **Secure** - Enterprise-grade security
|
|
||||||
- ✅ **Fast** - Instant authentication
|
|
||||||
|
|
||||||
### **For Developers:**
|
|
||||||
- ✅ **Easy integration** - Just redirect to our service
|
|
||||||
- ✅ **No OAuth management** - We handle everything
|
|
||||||
- ✅ **Centralized users** - Shared user database
|
|
||||||
- ✅ **Scalable** - Serve unlimited applications
|
|
||||||
|
|
||||||
### **For Administrators:**
|
|
||||||
- ✅ **Single control point** - Manage all OAuth in one place
|
|
||||||
- ✅ **Security oversight** - Monitor all authentication
|
|
||||||
- ✅ **Easy updates** - Update OAuth settings once
|
|
||||||
- ✅ **Cost effective** - One OAuth app for all services
|
|
||||||
|
|
||||||
## 🎯 **Use Cases**
|
|
||||||
|
|
||||||
- **SaaS platforms** - Multiple products, one authentication
|
|
||||||
- **Development teams** - Internal tools with GitHub login
|
|
||||||
- **Open source projects** - Contributor authentication
|
|
||||||
- **Enterprise** - Internal service authentication
|
|
||||||
- **API services** - Secure API access with GitHub OAuth
|
|
||||||
|
|
||||||
This service completely abstracts away OAuth complexity while providing enterprise-grade authentication for all your applications!
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
# Trackeep Main Controller
|
|
||||||
|
|
||||||
The **Trackeep Main Controller** is a centralized service that handles authentication, user management, and learning content management for all Trackeep instances. It transforms the original OAuth service into a comprehensive learning management system with a beautiful dashboard interface.
|
|
||||||
|
|
||||||
## 🛠️ **Tech Stack**
|
|
||||||
|
|
||||||
### **Backend:**
|
|
||||||
- **Go** - High-performance API server
|
|
||||||
- **Gin** - HTTP web framework
|
|
||||||
- **JWT** - Authentication tokens
|
|
||||||
- **OAuth2** - GitHub integration
|
|
||||||
|
|
||||||
### **Frontend:**
|
|
||||||
- **SolidJS** - Reactive UI framework
|
|
||||||
- **TypeScript** - Type-safe development
|
|
||||||
- **TailwindCSS** - Utility-first styling
|
|
||||||
- **Vite** - Fast build tool
|
|
||||||
|
|
||||||
### **Features:**
|
|
||||||
- **🔐 Centralized Authentication** - GitHub OAuth and email verification for all users
|
|
||||||
- **📚 Learning Management** - Create and manage free courses with YouTube, ZTM, GitHub, and Fireship resources
|
|
||||||
- **🖥️ Instance Management** - Register and monitor Trackeep instances
|
|
||||||
- **📊 Visual Dashboard** - Beautiful Trackeep-inspired UI for management
|
|
||||||
- **🔗 Secure Connections** - Automatic secure API key handling between instances
|
|
||||||
|
|
||||||
### **For Users:**
|
|
||||||
- **Free Learning** - All courses are completely free (price always $0.00)
|
|
||||||
- **No Instructors** - Self-paced learning with curated resources
|
|
||||||
- **Progress Tracking** - Monitor your learning progress across courses
|
|
||||||
- **Single Sign-On** - One GitHub account for all Trackeep instances
|
|
||||||
|
|
||||||
### **For Administrators:**
|
|
||||||
- **Course Creation** - Easy-to-use interface for creating learning paths
|
|
||||||
- **Resource Management** - Support for YouTube, Zero to Mastery, GitHub, Fireship links
|
|
||||||
- **Instance Monitoring** - Track all connected Trackeep instances
|
|
||||||
- **User Analytics** - Dashboard with comprehensive statistics
|
|
||||||
|
|
||||||
## 🚀 **Quick Start**
|
|
||||||
|
|
||||||
### **1. Setup the Main Controller**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Navigate to the main controller
|
|
||||||
cd oauth-service
|
|
||||||
|
|
||||||
# Install frontend dependencies
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Build the frontend
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Run the service (production mode)
|
|
||||||
go run main.go
|
|
||||||
```
|
|
||||||
|
|
||||||
### **2. Development Mode**
|
|
||||||
|
|
||||||
For development with hot reload:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Use the development script (starts both backend and frontend)
|
|
||||||
./dev.sh
|
|
||||||
|
|
||||||
# Or start manually:
|
|
||||||
# Terminal 1: Backend
|
|
||||||
go run main.go
|
|
||||||
|
|
||||||
# Terminal 2: Frontend dev server
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### **3. Access the Dashboard**
|
|
||||||
|
|
||||||
Open your browser to:
|
|
||||||
- **Dashboard**: http://localhost:9090/dashboard (production) or http://localhost:5174/dashboard (development)
|
|
||||||
- **Course Management**: http://localhost:9090/dashboard/courses
|
|
||||||
- **Instance Management**: http://localhost:9090/dashboard/instances
|
|
||||||
- **API Documentation**: http://localhost:9090/api/v1
|
|
||||||
|
|
||||||
### **4. GitHub OAuth Setup (Optional)**
|
|
||||||
|
|
||||||
For full authentication, set up GitHub OAuth:
|
|
||||||
|
|
||||||
1. Go to GitHub Settings → Developer settings → OAuth Apps
|
|
||||||
2. Create a new OAuth app with:
|
|
||||||
- **Application name**: Trackeep Main Controller
|
|
||||||
- **Homepage URL**: `http://localhost:9090`
|
|
||||||
- **Authorization callback URL**: `http://localhost:9090/auth/github/callback`
|
|
||||||
3. Add credentials to `.env` file
|
|
||||||
|
|
||||||
## 📡 **API Endpoints**
|
|
||||||
|
|
||||||
### **Authentication:**
|
|
||||||
- `GET /auth/github` - Initiate GitHub OAuth flow
|
|
||||||
- `GET /auth/github/callback` - Handle GitHub callback
|
|
||||||
- `POST /api/v1/email/send` - Send verification code
|
|
||||||
- `POST /api/v1/email/verify` - Verify email code
|
|
||||||
|
|
||||||
### **Course Management:**
|
|
||||||
- `GET /api/v1/courses` - List all courses
|
|
||||||
- `POST /api/v1/courses` - Create new course
|
|
||||||
- `GET /api/v1/courses/:id` - Get course details
|
|
||||||
- `PUT /api/v1/courses/:id` - Update course
|
|
||||||
- `DELETE /api/v1/courses/:id` - Delete course
|
|
||||||
- `GET /api/v1/courses/:id/resources` - Get course resources
|
|
||||||
- `POST /api/v1/courses/:id/resources` - Add course resource
|
|
||||||
|
|
||||||
### **User Progress:**
|
|
||||||
- `GET /api/v1/progress/:user_id` - Get user's all progress
|
|
||||||
- `GET /api/v1/progress/:user_id/:course_id` - Get course progress
|
|
||||||
- `POST /api/v1/progress/:user_id/:course_id` - Update progress
|
|
||||||
|
|
||||||
### **Instance Management:**
|
|
||||||
- `GET /api/v1/instances` - List all instances
|
|
||||||
- `POST /api/v1/instances` - Register new instance
|
|
||||||
- `GET /api/v1/instances/:id` - Get instance details
|
|
||||||
- `PUT /api/v1/instances/:id` - Update instance
|
|
||||||
- `DELETE /api/v1/instances/:id` - Delete instance
|
|
||||||
|
|
||||||
### **Dashboard:**
|
|
||||||
- `GET /api/v1/dashboard/stats` - Get dashboard statistics
|
|
||||||
- `GET /api/v1/dashboard/courses` - Get courses for dashboard
|
|
||||||
- `GET /api/v1/dashboard/users` - Get users for dashboard (admin only)
|
|
||||||
|
|
||||||
## 🏗️ **Architecture**
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
|
||||||
│ Trackeep App │ │ Main Controller │ │ GitHub API │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ OAuth Login ────┼───>│ /auth/github ────>│ OAuth Flow │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ Course API ─────┼───>│ /api/v1/courses │ │ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ Progress Sync ──┼───>│ /api/v1/progress │ │ │
|
|
||||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📚 **Course Structure**
|
|
||||||
|
|
||||||
### **Supported Resource Types:**
|
|
||||||
- **🎥 YouTube** - Video tutorials and playlists
|
|
||||||
- **🎓 Zero to Mastery** - ZTM courses and content
|
|
||||||
- **🐙 GitHub** - Repositories, projects, and code examples
|
|
||||||
- **🔥 Fireship** - Fast-paced tutorials and courses
|
|
||||||
- **🔗 Links** - Any other web resources
|
|
||||||
|
|
||||||
### **Course Example:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"title": "Complete Web Development Bootcamp",
|
|
||||||
"description": "Learn modern web development from scratch",
|
|
||||||
"category": "web-development",
|
|
||||||
"difficulty": "beginner",
|
|
||||||
"duration": 40,
|
|
||||||
"price": 0.0,
|
|
||||||
"tags": ["javascript", "react", "nodejs"],
|
|
||||||
"resources": [
|
|
||||||
{
|
|
||||||
"title": "Introduction to Web Development",
|
|
||||||
"type": "youtube",
|
|
||||||
"url": "https://www.youtube.com/watch?v=RW-sB6GeA_Q",
|
|
||||||
"duration": 45,
|
|
||||||
"is_required": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔒 **Security Features**
|
|
||||||
|
|
||||||
- **🔐 JWT Authentication** - Secure token-based authentication
|
|
||||||
- **🛡️ API Key Management** - Automatic secure key generation for instances
|
|
||||||
- **🔗 CORS Support** - Configurable allowed origins
|
|
||||||
- **✅ CSRF Protection** - State parameter validation
|
|
||||||
- **📊 Rate Limiting** - GitHub API rate limit awareness
|
|
||||||
|
|
||||||
## 🎨 **Dashboard Features**
|
|
||||||
|
|
||||||
### **Main Dashboard:**
|
|
||||||
- 📊 Real-time statistics
|
|
||||||
- 📚 Recent courses overview
|
|
||||||
- 🖥️ Active instances monitoring
|
|
||||||
- 📈 User progress analytics
|
|
||||||
|
|
||||||
### **Course Management:**
|
|
||||||
- ➕ Easy course creation wizard
|
|
||||||
- ✏️ Visual course editing
|
|
||||||
- 🏷️ Tag-based organization
|
|
||||||
- 📱 Responsive design
|
|
||||||
|
|
||||||
### **Instance Management:**
|
|
||||||
- 🔗 Secure instance registration
|
|
||||||
- 📊 Connection status monitoring
|
|
||||||
- 🔑 API key management
|
|
||||||
- 📈 Instance analytics
|
|
||||||
|
|
||||||
## 🔧 **Configuration**
|
|
||||||
|
|
||||||
### **Environment Variables:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Service Configuration
|
|
||||||
PORT=9090
|
|
||||||
JWT_SECRET=your-super-secret-jwt-key
|
|
||||||
|
|
||||||
# GitHub OAuth (Optional)
|
|
||||||
GITHUB_CLIENT_ID=your_github_client_id
|
|
||||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
|
||||||
GITHUB_REDIRECT_URL=http://localhost:9090/auth/github/callback
|
|
||||||
|
|
||||||
# Email Verification (Optional)
|
|
||||||
SMTP_HOST=smtp.purelymail.com
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USERNAME=your_purelymail_username
|
|
||||||
SMTP_PASSWORD=your_purelymail_password
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 **Production Deployment**
|
|
||||||
|
|
||||||
### **Docker Deployment:**
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
FROM golang:1.21-alpine AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY . .
|
|
||||||
RUN go mod download && go build -o trackeep-controller
|
|
||||||
|
|
||||||
FROM alpine:latest
|
|
||||||
RUN apk --no-cache add ca-certificates
|
|
||||||
WORKDIR /root/
|
|
||||||
COPY --from=builder /app/trackeep-controller .
|
|
||||||
COPY .env .
|
|
||||||
COPY templates/ ./templates/
|
|
||||||
EXPOSE 9090
|
|
||||||
CMD ["./trackeep-controller"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Docker Compose:**
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: '3.8'
|
|
||||||
services:
|
|
||||||
trackeep-controller:
|
|
||||||
build: ./oauth-service
|
|
||||||
ports:
|
|
||||||
- "9090:9090"
|
|
||||||
environment:
|
|
||||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
|
||||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
|
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
|
||||||
restart: unless-stopped
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 **Benefits**
|
|
||||||
|
|
||||||
### **For Learners:**
|
|
||||||
- ✅ **Completely Free** - All courses are $0.00
|
|
||||||
- ✅ **Self-Paced** - Learn at your own speed
|
|
||||||
- ✅ **Quality Content** - Curated YouTube, ZTM, GitHub, Fireship resources
|
|
||||||
- ✅ **Progress Tracking** - Monitor your learning journey
|
|
||||||
- ✅ **Single Sign-On** - One account for all Trackeep instances
|
|
||||||
|
|
||||||
### **For Administrators:**
|
|
||||||
- ✅ **Easy Management** - Beautiful dashboard interface
|
|
||||||
- ✅ **Secure Connections** - Automatic API key handling
|
|
||||||
- ✅ **Scalable** - Serve unlimited instances
|
|
||||||
- ✅ **Analytics** - Comprehensive usage statistics
|
|
||||||
- ✅ **Zero Setup** - Works out of the box with sample data
|
|
||||||
|
|
||||||
### **For Developers:**
|
|
||||||
- ✅ **RESTful API** - Clean, well-documented endpoints
|
|
||||||
- ✅ **Flexible Resources** - Support for multiple content types
|
|
||||||
- ✅ **Secure by Default** - Built-in authentication and authorization
|
|
||||||
- ✅ **Easy Integration** - Simple API key-based connections
|
|
||||||
|
|
||||||
## 🎯 **Use Cases**
|
|
||||||
|
|
||||||
- **🎓 Educational Platforms** - Free learning management system
|
|
||||||
- **👥 Developer Communities** - Share learning resources
|
|
||||||
- **🏢 Corporate Training** - Internal skill development
|
|
||||||
- **📚 Course Aggregators** - Curate learning content
|
|
||||||
- **🚀 Startup Education** - Onboarding and training programs
|
|
||||||
|
|
||||||
## 🔄 **Multi-Instance Support**
|
|
||||||
|
|
||||||
The Main Controller can serve multiple Trackeep instances:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Instance 1
|
|
||||||
fetch('http://localhost:9090/api/v1/courses', {
|
|
||||||
headers: { 'Authorization': 'Bearer instance1_api_key' }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Instance 2
|
|
||||||
fetch('http://localhost:9090/api/v1/courses', {
|
|
||||||
headers: { 'Authorization': 'Bearer instance2_api_key' }
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Each instance gets its own API key and can securely access the centralized course catalog and user management.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Trackeep Main Controller** - Complete learning management system with beautiful dashboard and secure multi-instance support. 🚀
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
# Trackeep Integration Guide
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
This OAuth service is designed **only for authentication**. Trackeep instances (user-hosted) handle all GitHub data tracking directly.
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### 1. User Authentication Flow
|
|
||||||
1. User clicks "Login with GitHub" in Trackeep
|
|
||||||
2. Trackeep redirects to: `https://oauth.tdvorak.dev/auth/github?redirect_uri=https://user-trackeep-instance.com`
|
|
||||||
3. OAuth service handles GitHub authentication
|
|
||||||
4. OAuth service redirects back: `https://user-trackeep-instance.com/auth/callback?token=JWT&user=username`
|
|
||||||
|
|
||||||
### 2. What Trackeep Receives
|
|
||||||
The JWT token contains:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": 123,
|
|
||||||
"github_id": 456789,
|
|
||||||
"username": "johndoe",
|
|
||||||
"email": "john@example.com",
|
|
||||||
"access_token": "gho_16C7e42F292c6912E7710c838347Ae178B4a",
|
|
||||||
"token_type": "bearer",
|
|
||||||
"expires_at": 1738123456,
|
|
||||||
"exp": 1738123456,
|
|
||||||
"iat": 1737518656
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Trackeep GitHub API Access
|
|
||||||
Trackeep instances can now make GitHub API calls using the user's `access_token`:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Example: Get user repositories
|
|
||||||
const response = await fetch('https://api.github.com/user/repos', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
|
||||||
'Accept': 'application/vnd.github.v3+json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Example: Get commits for a repo
|
|
||||||
const commits = await fetch(`https://api.github.com/repos/${owner}/${repo}/commits`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
|
||||||
'Accept': 'application/vnd.github.v3+json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Trackeep Implementation Guide
|
|
||||||
|
|
||||||
### 1. OAuth Login Button
|
|
||||||
```html
|
|
||||||
<a href="https://oauth.tdvorak.dev/auth/github?redirect_uri=https://your-trackeep-instance.com">
|
|
||||||
Login with GitHub
|
|
||||||
</a>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Handle OAuth Callback
|
|
||||||
```javascript
|
|
||||||
// In your /auth/callback route
|
|
||||||
async function handleOAuthCallback(req, res) {
|
|
||||||
const { token, user: username } = req.query;
|
|
||||||
|
|
||||||
// Decode and verify JWT
|
|
||||||
const jwtPayload = decodeJWT(token);
|
|
||||||
|
|
||||||
// Store user session
|
|
||||||
req.session.user = {
|
|
||||||
id: jwtPayload.user_id,
|
|
||||||
username: jwtPayload.username,
|
|
||||||
email: jwtPayload.email,
|
|
||||||
githubAccessToken: jwtPayload.access_token,
|
|
||||||
tokenType: jwtPayload.token_type,
|
|
||||||
expiresAt: jwtPayload.expires_at
|
|
||||||
};
|
|
||||||
|
|
||||||
// Redirect to dashboard
|
|
||||||
res.redirect('/dashboard');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. GitHub API Helper
|
|
||||||
```javascript
|
|
||||||
class GitHubAPI {
|
|
||||||
constructor(accessToken) {
|
|
||||||
this.accessToken = accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
async makeRequest(url) {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${this.accessToken}`,
|
|
||||||
'Accept': 'application/vnd.github.v3+json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserRepos() {
|
|
||||||
return this.makeRequest('https://api.github.com/user/repos');
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRepoCommits(owner, repo) {
|
|
||||||
return this.makeRequest(`https://api.github.com/repos/${owner}/${repo}/commits`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRepoPulls(owner, repo) {
|
|
||||||
return this.makeRequest(`https://api.github.com/repos/${owner}/${repo}/pulls`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBranches(owner, repo) {
|
|
||||||
return this.makeRequest(`https://api.github.com/repos/${owner}/${repo}/branches`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Track Data Collection
|
|
||||||
```javascript
|
|
||||||
// Example: Track repository activity
|
|
||||||
async function trackRepositoryActivity(user, repoFullName) {
|
|
||||||
const [owner, repo] = repoFullName.split('/');
|
|
||||||
const github = new GitHubAPI(user.githubAccessToken);
|
|
||||||
|
|
||||||
// Get commits
|
|
||||||
const commits = await github.getRepoCommits(owner, repo);
|
|
||||||
|
|
||||||
// Get pull requests
|
|
||||||
const pulls = await github.getRepoPulls(owner, repo);
|
|
||||||
|
|
||||||
// Store in your local database
|
|
||||||
await storeActivityData({
|
|
||||||
userId: user.id,
|
|
||||||
repo: repoFullName,
|
|
||||||
commits: commits.length,
|
|
||||||
pullRequests: pulls.length,
|
|
||||||
lastActivity: new Date()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
### 1. Token Storage
|
|
||||||
- Store GitHub access tokens securely (encrypted at rest)
|
|
||||||
- Never expose tokens in client-side JavaScript
|
|
||||||
- Use secure, HTTP-only cookies for session management
|
|
||||||
|
|
||||||
### 2. Token Expiration
|
|
||||||
- Monitor `expires_at` field in JWT
|
|
||||||
- Refresh tokens before expiration if needed
|
|
||||||
- Handle token expiry gracefully
|
|
||||||
|
|
||||||
### 3. Rate Limiting
|
|
||||||
- GitHub API has rate limits (5,000 requests/hour for authenticated users)
|
|
||||||
- Implement caching to reduce API calls
|
|
||||||
- Handle rate limit responses (HTTP 429)
|
|
||||||
|
|
||||||
## Available GitHub Scopes
|
|
||||||
|
|
||||||
The OAuth service requests these scopes:
|
|
||||||
- `user:email` - Read user email addresses
|
|
||||||
- `read:user` - Read user profile data
|
|
||||||
- `repo` - Access to repositories (full control)
|
|
||||||
|
|
||||||
This allows Trackeep instances to:
|
|
||||||
- Read repository data
|
|
||||||
- Access commit history
|
|
||||||
- Monitor pull requests
|
|
||||||
- Track branch activity
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### OAuth Service
|
|
||||||
- `GET /auth/github` - Initiate OAuth flow
|
|
||||||
- `GET /auth/github/callback` - Handle GitHub callback
|
|
||||||
- `GET /api/v1/user/me` - Get current user info
|
|
||||||
|
|
||||||
### GitHub API (via access token)
|
|
||||||
- `GET /user/repos` - User repositories
|
|
||||||
- `GET /repos/{owner}/{repo}/commits` - Repository commits
|
|
||||||
- `GET /repos/{owner}/{repo}/pulls` - Pull requests
|
|
||||||
- `GET /repos/{owner}/{repo}/branches` - Branches
|
|
||||||
- And all other GitHub API endpoints
|
|
||||||
|
|
||||||
## Benefits of This Architecture
|
|
||||||
|
|
||||||
1. **Separation of Concerns** - OAuth service only handles authentication
|
|
||||||
2. **User Privacy** - GitHub data stays in user's Trackeep instance
|
|
||||||
3. **Scalability** - Each user instance handles its own GitHub API calls
|
|
||||||
4. **Security** - No centralized GitHub data storage
|
|
||||||
5. **Flexibility** - Trackeep can implement custom tracking logic
|
|
||||||
|
|
||||||
## Example Implementation
|
|
||||||
|
|
||||||
See the `examples/` directory for complete implementation examples in different frameworks.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Trackeep Main Controller Development Script
|
|
||||||
# This script starts both the backend API server and frontend dev server
|
|
||||||
|
|
||||||
echo "🚀 Starting Trackeep Main Controller Development Environment..."
|
|
||||||
|
|
||||||
# Check if we're in the right directory
|
|
||||||
if [ ! -f "main.go" ]; then
|
|
||||||
echo "❌ Error: Please run this script from the oauth-service directory"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Start backend server in background
|
|
||||||
echo "🔧 Starting backend API server on port 9090..."
|
|
||||||
go run main.go &
|
|
||||||
BACKEND_PID=$!
|
|
||||||
|
|
||||||
# Wait a moment for backend to start
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# Start frontend dev server
|
|
||||||
echo "🎨 Starting frontend dev server on port 5174..."
|
|
||||||
npm run dev &
|
|
||||||
FRONTEND_PID=$!
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ Trackeep Main Controller is running!"
|
|
||||||
echo ""
|
|
||||||
echo "📊 Dashboard: http://localhost:5174/dashboard"
|
|
||||||
echo "📚 Courses: http://localhost:5174/dashboard/courses"
|
|
||||||
echo "🖥️ Instances: http://localhost:5174/dashboard/instances"
|
|
||||||
echo "🔧 API: http://localhost:9090/api/v1"
|
|
||||||
echo "💚 Health Check: http://localhost:9090/health"
|
|
||||||
echo ""
|
|
||||||
echo "Press Ctrl+C to stop both servers"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Function to kill both processes on exit
|
|
||||||
cleanup() {
|
|
||||||
echo ""
|
|
||||||
echo "🛑 Stopping servers..."
|
|
||||||
kill $BACKEND_PID 2>/dev/null
|
|
||||||
kill $FRONTEND_PID 2>/dev/null
|
|
||||||
echo "✅ All servers stopped"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Set up trap to kill processes on Ctrl+C
|
|
||||||
trap cleanup INT
|
|
||||||
|
|
||||||
# Wait for both processes
|
|
||||||
wait
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
oauth-service:
|
|
||||||
build: .
|
|
||||||
container_name: github-oauth-service
|
|
||||||
ports:
|
|
||||||
- "9090:9090"
|
|
||||||
environment:
|
|
||||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
|
|
||||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
|
|
||||||
- GITHUB_REDIRECT_URL=http://localhost:9090/auth/github/callback
|
|
||||||
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
|
||||||
- PORT=9090
|
|
||||||
- GIN_MODE=release
|
|
||||||
- CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080,https://yourdomain.com
|
|
||||||
- DEFAULT_CLIENT_URL=http://localhost:5173
|
|
||||||
- GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET}
|
|
||||||
volumes:
|
|
||||||
- ./.env:/app/.env:ro
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- oauth-network
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9090/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 40s
|
|
||||||
|
|
||||||
# Optional: Redis for session storage (for production)
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
container_name: oauth-redis
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
volumes:
|
|
||||||
- redis-data:/data
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- oauth-network
|
|
||||||
command: redis-server --appendonly yes
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
redis-data:
|
|
||||||
|
|
||||||
networks:
|
|
||||||
oauth-network:
|
|
||||||
driver: bridge
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
module trackeep-main-controller
|
|
||||||
|
|
||||||
go 1.21
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/gin-gonic/gin v1.9.1
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
|
||||||
github.com/joho/godotenv v1.4.0
|
|
||||||
golang.org/x/oauth2 v0.8.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
|
||||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
|
||||||
github.com/golang/protobuf v1.5.2 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
|
||||||
github.com/leodido/go-urn v1.2.4 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
|
||||||
golang.org/x/crypto v0.9.0 // indirect
|
|
||||||
golang.org/x/net v0.10.0 // indirect
|
|
||||||
golang.org/x/sys v0.8.0 // indirect
|
|
||||||
golang.org/x/text v0.9.0 // indirect
|
|
||||||
google.golang.org/appengine v1.6.7 // indirect
|
|
||||||
google.golang.org/protobuf v1.30.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
|
||||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
|
||||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
|
||||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
|
||||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
|
||||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
|
||||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
|
||||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
|
||||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
|
||||||
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
|
||||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
|
||||||
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
|
|
||||||
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
|
||||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
|
||||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
|
||||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
|
||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
|
||||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
|
||||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
|
||||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
|
||||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
|
||||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
|
||||||
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
|
|
||||||
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
|
||||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
|
||||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
|
||||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
|
||||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Trackeep Main Controller</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script src="/src/index.tsx" type="module"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "trackeep-main-controller-ui",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Trackeep Main Controller Frontend",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"solid-js": "^1.8.7",
|
|
||||||
"@solidjs/router": "^0.8.3",
|
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"autoprefixer": "^10.4.16",
|
|
||||||
"postcss": "^8.4.32"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^20.10.0",
|
|
||||||
"typescript": "^5.3.0",
|
|
||||||
"vite": "^5.0.0",
|
|
||||||
"vite-plugin-solid": "^2.8.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# GitHub OAuth Service Setup Script
|
|
||||||
|
|
||||||
echo "🚀 Setting up GitHub OAuth Service..."
|
|
||||||
|
|
||||||
# Create directory if it doesn't exist
|
|
||||||
mkdir -p oauth-service
|
|
||||||
cd oauth-service
|
|
||||||
|
|
||||||
# Check if Go is installed
|
|
||||||
if ! command -v go &> /dev/null; then
|
|
||||||
echo "❌ Go is not installed. Please install Go first."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Initialize Go module
|
|
||||||
echo "📦 Initializing Go module..."
|
|
||||||
go mod init github-oauth-service
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
echo "📥 Installing dependencies..."
|
|
||||||
go get github.com/gin-gonic/gin
|
|
||||||
go get github.com/golang-jwt/jwt/v5
|
|
||||||
go get github.com/joho/godotenv
|
|
||||||
go get golang.org/x/oauth2
|
|
||||||
|
|
||||||
# Create .env file if it doesn't exist
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
echo "📝 Creating .env file from template..."
|
|
||||||
cp .env.example .env
|
|
||||||
echo "⚠️ Please edit .env file with your GitHub OAuth credentials"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Make the service executable
|
|
||||||
chmod +x main.go
|
|
||||||
|
|
||||||
echo "✅ GitHub OAuth Service setup complete!"
|
|
||||||
echo ""
|
|
||||||
echo "📋 Next steps:"
|
|
||||||
echo "1. Edit oauth-service/.env with your GitHub OAuth credentials"
|
|
||||||
echo "2. Run: cd oauth-service && go run main.go"
|
|
||||||
echo "3. Service will start on port 9090"
|
|
||||||
echo ""
|
|
||||||
echo "🔗 OAuth endpoints:"
|
|
||||||
echo "- Initiate: http://localhost:9090/auth/github"
|
|
||||||
echo "- Callback: http://localhost:9090/auth/github/callback"
|
|
||||||
echo "- Health: http://localhost:9090/health"
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Router, Route } from '@solidjs/router';
|
|
||||||
import { Dashboard } from './components/Dashboard';
|
|
||||||
import { CourseManagement } from './components/CourseManagement';
|
|
||||||
import { InstanceManagement } from './components/InstanceManagement';
|
|
||||||
import './styles.css';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<Router>
|
|
||||||
<Route path="/" component={Dashboard} />
|
|
||||||
<Route path="/dashboard" component={Dashboard} />
|
|
||||||
<Route path="/dashboard/courses" component={CourseManagement} />
|
|
||||||
<Route path="/dashboard/instances" component={InstanceManagement} />
|
|
||||||
</Router>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,537 +0,0 @@
|
|||||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
|
||||||
|
|
||||||
interface Course {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
category: string;
|
|
||||||
difficulty: 'beginner' | 'intermediate' | 'advanced';
|
|
||||||
duration: number;
|
|
||||||
price: number;
|
|
||||||
thumbnail: string;
|
|
||||||
tags: string[];
|
|
||||||
resources: CourseResource[];
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
created_by: number;
|
|
||||||
is_active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CourseResource {
|
|
||||||
id: number;
|
|
||||||
course_id: number;
|
|
||||||
title: string;
|
|
||||||
type: 'youtube' | 'ztm' | 'github' | 'fireship' | 'link';
|
|
||||||
url: string;
|
|
||||||
description: string;
|
|
||||||
duration: number;
|
|
||||||
order: number;
|
|
||||||
is_required: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Instance {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
api_key: string;
|
|
||||||
is_active: boolean;
|
|
||||||
version: string;
|
|
||||||
created_at: string;
|
|
||||||
last_sync: string;
|
|
||||||
admin_user_id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CourseManagement = () => {
|
|
||||||
const [courses, setCourses] = createSignal<Course[]>([]);
|
|
||||||
const [instances, setInstances] = createSignal<Instance[]>([]);
|
|
||||||
const [loading, setLoading] = createSignal(true);
|
|
||||||
const [showModal, setShowModal] = createSignal(false);
|
|
||||||
const [editingCourse, setEditingCourse] = createSignal<Course | null>(null);
|
|
||||||
const [tags, setTags] = createSignal<string[]>([]);
|
|
||||||
const [resources, setResources] = createSignal<CourseResource[]>([]);
|
|
||||||
const [tagInput, setTagInput] = createSignal('');
|
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [formData, setFormData] = createSignal({
|
|
||||||
title: '',
|
|
||||||
category: '',
|
|
||||||
difficulty: '' as 'beginner' | 'intermediate' | 'advanced' | '',
|
|
||||||
duration: '',
|
|
||||||
description: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const categories = [
|
|
||||||
'programming',
|
|
||||||
'design',
|
|
||||||
'business',
|
|
||||||
'marketing',
|
|
||||||
'data-science',
|
|
||||||
'web-development',
|
|
||||||
'mobile-development',
|
|
||||||
'devops',
|
|
||||||
'other'
|
|
||||||
];
|
|
||||||
|
|
||||||
const resourceTypes = [
|
|
||||||
{ value: 'youtube', label: 'YouTube', color: '#ff0000' },
|
|
||||||
{ value: 'ztm', label: 'ZTM', color: '#3b82f6' },
|
|
||||||
{ value: 'github', label: 'GitHub', color: '#333' },
|
|
||||||
{ value: 'fireship', label: 'Fireship', color: '#f59e0b' },
|
|
||||||
{ value: 'link', label: 'Link', color: '#6b7280' }
|
|
||||||
];
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
await loadCourses();
|
|
||||||
await loadInstances();
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadCourses = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/courses');
|
|
||||||
const data = await response.json();
|
|
||||||
setCourses(data.courses || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading courses:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadInstances = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/instances');
|
|
||||||
const data = await response.json();
|
|
||||||
setInstances(data.instances || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading instances:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreateModal = () => {
|
|
||||||
setEditingCourse(null);
|
|
||||||
setFormData({
|
|
||||||
title: '',
|
|
||||||
category: '',
|
|
||||||
difficulty: '',
|
|
||||||
duration: '',
|
|
||||||
description: '',
|
|
||||||
});
|
|
||||||
setTags([]);
|
|
||||||
setResources([]);
|
|
||||||
setShowModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditModal = (course: Course) => {
|
|
||||||
setEditingCourse(course);
|
|
||||||
setFormData({
|
|
||||||
title: course.title,
|
|
||||||
category: course.category,
|
|
||||||
difficulty: course.difficulty,
|
|
||||||
duration: course.duration.toString(),
|
|
||||||
description: course.description,
|
|
||||||
});
|
|
||||||
setTags(course.tags || []);
|
|
||||||
setResources(course.resources || []);
|
|
||||||
setShowModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setShowModal(false);
|
|
||||||
setEditingCourse(null);
|
|
||||||
setTags([]);
|
|
||||||
setResources([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addTag = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
const value = tagInput().trim();
|
|
||||||
if (value && !tags().includes(value)) {
|
|
||||||
setTags([...tags(), value]);
|
|
||||||
setTagInput('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeTag = (tagToRemove: string) => {
|
|
||||||
setTags(tags().filter(tag => tag !== tagToRemove));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addResource = () => {
|
|
||||||
setResources([...resources(), {
|
|
||||||
id: Date.now(),
|
|
||||||
course_id: editingCourse()?.id || 0,
|
|
||||||
title: '',
|
|
||||||
type: 'link',
|
|
||||||
url: '',
|
|
||||||
description: '',
|
|
||||||
duration: 0,
|
|
||||||
order: resources().length + 1,
|
|
||||||
is_required: false
|
|
||||||
}]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateResource = (index: number, field: keyof CourseResource, value: any) => {
|
|
||||||
const updatedResources = [...resources()];
|
|
||||||
updatedResources[index] = { ...updatedResources[index], [field]: value };
|
|
||||||
setResources(updatedResources);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeResource = (index: number) => {
|
|
||||||
setResources(resources().filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveCourse = async () => {
|
|
||||||
try {
|
|
||||||
const courseData = {
|
|
||||||
...formData(),
|
|
||||||
duration: parseInt(formData().duration),
|
|
||||||
tags: tags(),
|
|
||||||
resources: resources()
|
|
||||||
};
|
|
||||||
|
|
||||||
const url = editingCourse() ? `/api/v1/courses/${editingCourse()!.id}` : '/api/v1/courses';
|
|
||||||
const method = editingCourse() ? 'PUT' : 'POST';
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify(courseData)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
closeModal();
|
|
||||||
await loadCourses();
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert('Error: ' + (error.error || 'Failed to save course'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving course:', error);
|
|
||||||
alert('Error: Failed to save course');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteCourse = async (courseId: number) => {
|
|
||||||
if (!confirm('Are you sure you want to delete this course?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/courses/${courseId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
await loadCourses();
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert('Error: ' + (error.error || 'Failed to delete course'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting course:', error);
|
|
||||||
alert('Error: Failed to delete course');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDifficultyColor = (difficulty: string) => {
|
|
||||||
switch (difficulty) {
|
|
||||||
case 'beginner': return 'bg-green-100 text-green-800';
|
|
||||||
case 'intermediate': return 'bg-orange-100 text-orange-800';
|
|
||||||
case 'advanced': return 'bg-red-100 text-red-800';
|
|
||||||
default: return 'bg-gray-100 text-gray-800';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="min-h-screen bg-gradient-to-br from-indigo-500 to-purple-600 p-6">
|
|
||||||
<div class="max-w-7xl mx-auto">
|
|
||||||
{/* Header */}
|
|
||||||
<header class="bg-white/95 backdrop-blur-sm rounded-2xl p-6 mb-8 shadow-xl">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-10 h-10 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center text-white font-bold">
|
|
||||||
T
|
|
||||||
</div>
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Trackeep Controller</h1>
|
|
||||||
</div>
|
|
||||||
<nav class="flex gap-2">
|
|
||||||
<a href="/dashboard" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Dashboard</a>
|
|
||||||
<a href="/dashboard/courses" class="px-4 py-2 rounded-lg bg-indigo-500 text-white hover:bg-indigo-600 transition-colors">Courses</a>
|
|
||||||
<a href="/dashboard/instances" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Instances</a>
|
|
||||||
<a href="/api/v1/user/me" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Profile</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div class="bg-white/95 backdrop-blur-sm rounded-2xl p-6 mb-8 shadow-xl">
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
|
||||||
<h2 class="text-2xl font-semibold text-gray-900">Course Management</h2>
|
|
||||||
<button
|
|
||||||
onClick={openCreateModal}
|
|
||||||
class="px-6 py-3 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span>+</span> Create New Course
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={loading()} fallback={
|
|
||||||
<Show when={courses().length > 0} fallback={
|
|
||||||
<div class="text-center py-16 text-gray-500">
|
|
||||||
<div class="text-6xl mb-4 opacity-50">📚</div>
|
|
||||||
<div class="text-xl font-semibold mb-2">No courses yet</div>
|
|
||||||
<p>Create your first learning course to get started!</p>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<For each={courses()}>
|
|
||||||
{(course) => (
|
|
||||||
<div class="bg-white rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden group">
|
|
||||||
<div class="h-48 bg-gradient-to-r from-indigo-500 to-purple-600 relative">
|
|
||||||
<div class="absolute inset-0 flex items-center justify-center text-white text-5xl font-bold">
|
|
||||||
{course.title.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div class="absolute top-4 right-4 bg-white/90 backdrop-blur-sm px-3 py-1 rounded-full text-sm font-semibold text-gray-900">
|
|
||||||
FREE
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">{course.title}</h3>
|
|
||||||
<p class="text-gray-600 text-sm mb-4 line-clamp-2">{course.description}</p>
|
|
||||||
<div class="flex justify-between items-center mb-4 text-sm text-gray-500">
|
|
||||||
<span>{course.category}</span>
|
|
||||||
<span class={`px-2 py-1 rounded-full text-xs font-medium ${getDifficultyColor(course.difficulty)}`}>
|
|
||||||
{course.difficulty}
|
|
||||||
</span>
|
|
||||||
<span>{course.duration}h</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-sm"
|
|
||||||
onClick={() => window.open(`/api/v1/courses/${course.id}`, '_blank')}
|
|
||||||
>
|
|
||||||
👁️ View
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-sm"
|
|
||||||
onClick={() => openEditModal(course)}
|
|
||||||
>
|
|
||||||
✏️ Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="flex-1 px-3 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition-colors text-sm"
|
|
||||||
onClick={() => deleteCourse(course.id)}
|
|
||||||
>
|
|
||||||
🗑️ Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
}>
|
|
||||||
<div class="text-center py-8 text-gray-500">Loading courses...</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Course Modal */}
|
|
||||||
<Show when={showModal()}>
|
|
||||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div class="bg-white rounded-2xl p-8 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
|
||||||
<h3 class="text-2xl font-semibold text-gray-900">
|
|
||||||
{editingCourse() ? 'Edit Course' : 'Create New Course'}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={closeModal}
|
|
||||||
class="text-gray-500 hover:text-gray-700 text-2xl font-bold"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Course Title *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData().title}
|
|
||||||
onInput={(e) => setFormData({ ...formData(), title: e.currentTarget.value })}
|
|
||||||
placeholder="Course Title"
|
|
||||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Category *</label>
|
|
||||||
<select
|
|
||||||
value={formData().category}
|
|
||||||
onChange={(e) => setFormData({ ...formData(), category: e.currentTarget.value })}
|
|
||||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Select Category</option>
|
|
||||||
<For each={categories}>
|
|
||||||
{(category) => <option value={category}>{category}</option>}
|
|
||||||
</For>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Difficulty *</label>
|
|
||||||
<select
|
|
||||||
value={formData().difficulty}
|
|
||||||
onChange={(e) => setFormData({ ...formData(), difficulty: e.currentTarget.value as any })}
|
|
||||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Select Difficulty</option>
|
|
||||||
<option value="beginner">Beginner</option>
|
|
||||||
<option value="intermediate">Intermediate</option>
|
|
||||||
<option value="advanced">Advanced</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Duration (hours) *</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={formData().duration}
|
|
||||||
onInput={(e) => setFormData({ ...formData(), duration: e.currentTarget.value })}
|
|
||||||
min="1"
|
|
||||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Description *</label>
|
|
||||||
<textarea
|
|
||||||
value={formData().description}
|
|
||||||
onInput={(e) => setFormData({ ...formData(), description: e.currentTarget.value })}
|
|
||||||
placeholder="Course description"
|
|
||||||
rows={4}
|
|
||||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Tags (press Enter to add)</label>
|
|
||||||
<div class="flex flex-wrap gap-2 p-3 border-2 border-gray-200 rounded-lg min-h-[50px] cursor-text" onClick={(e: MouseEvent) => {
|
|
||||||
const target = e.currentTarget as HTMLElement;
|
|
||||||
const input = target.querySelector('input') as HTMLInputElement;
|
|
||||||
input?.focus();
|
|
||||||
}}>
|
|
||||||
<For each={tags()}>
|
|
||||||
{(tag) => (
|
|
||||||
<span class="bg-indigo-500 text-white px-2 py-1 rounded-md text-sm flex items-center gap-1">
|
|
||||||
{tag}
|
|
||||||
<button type="button" onClick={() => removeTag(tag)} class="font-bold">×</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={tagInput()}
|
|
||||||
onInput={(e) => setTagInput(e.currentTarget.value)}
|
|
||||||
onKeyDown={addTag}
|
|
||||||
placeholder="Add tags..."
|
|
||||||
class="border-none outline-none flex-1 min-w-[100px] p-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h4 class="text-lg font-medium text-gray-900">Course Resources</h4>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={addResource}
|
|
||||||
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<span>+</span> Add Resource
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<For each={resources()}>
|
|
||||||
{(resource, index) => (
|
|
||||||
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
|
|
||||||
<div class="flex-1 space-y-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Resource Title"
|
|
||||||
value={resource.title}
|
|
||||||
onInput={(e) => updateResource(index(), 'title', e.currentTarget.value)}
|
|
||||||
class="w-full p-2 border border-gray-200 rounded-md"
|
|
||||||
/>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<select
|
|
||||||
value={resource.type}
|
|
||||||
onChange={(e) => updateResource(index(), 'type', e.currentTarget.value)}
|
|
||||||
class="p-2 border border-gray-200 rounded-md"
|
|
||||||
>
|
|
||||||
<For each={resourceTypes}>
|
|
||||||
{(type) => <option value={type.value}>{type.label}</option>}
|
|
||||||
</For>
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
placeholder="URL"
|
|
||||||
value={resource.url}
|
|
||||||
onInput={(e) => updateResource(index(), 'url', e.currentTarget.value)}
|
|
||||||
class="flex-1 p-2 border border-gray-200 rounded-md"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder="Duration (min)"
|
|
||||||
value={resource.duration}
|
|
||||||
onInput={(e) => updateResource(index(), 'duration', parseInt(e.currentTarget.value) || 0)}
|
|
||||||
class="w-24 p-2 border border-gray-200 rounded-md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeResource(index())}
|
|
||||||
class="px-3 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeModal}
|
|
||||||
class="px-6 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={saveCourse}
|
|
||||||
class="px-6 py-3 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors"
|
|
||||||
>
|
|
||||||
Save Course
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
|
||||||
|
|
||||||
interface DashboardStats {
|
|
||||||
total_users: number;
|
|
||||||
total_courses: number;
|
|
||||||
total_instances: number;
|
|
||||||
active_courses: number;
|
|
||||||
total_progress: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Course {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
category: string;
|
|
||||||
difficulty: string;
|
|
||||||
duration: number;
|
|
||||||
thumbnail: string;
|
|
||||||
created_at: string;
|
|
||||||
is_active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Instance {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
version: string;
|
|
||||||
is_active: boolean;
|
|
||||||
created_at: string;
|
|
||||||
last_sync: string;
|
|
||||||
api_key: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Dashboard = () => {
|
|
||||||
const [stats, setStats] = createSignal<DashboardStats>({
|
|
||||||
total_users: 0,
|
|
||||||
total_courses: 0,
|
|
||||||
total_instances: 0,
|
|
||||||
active_courses: 0,
|
|
||||||
total_progress: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
const [courses, setCourses] = createSignal<Course[]>([]);
|
|
||||||
const [instances, setInstances] = createSignal<Instance[]>([]);
|
|
||||||
const [loading, setLoading] = createSignal(true);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
await Promise.all([
|
|
||||||
loadStats(),
|
|
||||||
loadCourses(),
|
|
||||||
loadInstances()
|
|
||||||
]);
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadStats = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/dashboard/stats');
|
|
||||||
const data = await response.json();
|
|
||||||
setStats(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading stats:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadCourses = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/dashboard/courses');
|
|
||||||
const data = await response.json();
|
|
||||||
setCourses(data.courses || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading courses:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadInstances = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/instances');
|
|
||||||
const data = await response.json();
|
|
||||||
setInstances(data.instances || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading instances:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
if (!dateString) return 'Never';
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDifficultyColor = (difficulty: string) => {
|
|
||||||
switch (difficulty) {
|
|
||||||
case 'beginner': return 'bg-green-100 text-green-800';
|
|
||||||
case 'intermediate': return 'bg-orange-100 text-orange-800';
|
|
||||||
case 'advanced': return 'bg-red-100 text-red-800';
|
|
||||||
default: return 'bg-gray-100 text-gray-800';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="min-h-screen bg-gradient-to-br from-indigo-500 to-purple-600 p-6">
|
|
||||||
<div class="max-w-7xl mx-auto">
|
|
||||||
{/* Header */}
|
|
||||||
<header class="glass rounded-2xl p-6 mb-8 shadow-xl">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-10 h-10 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center text-white font-bold">
|
|
||||||
T
|
|
||||||
</div>
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Trackeep Controller</h1>
|
|
||||||
</div>
|
|
||||||
<nav class="flex gap-2">
|
|
||||||
<a href="/dashboard" class="px-4 py-2 rounded-lg bg-indigo-500 text-white hover:bg-indigo-600 transition-colors">Dashboard</a>
|
|
||||||
<a href="/dashboard/courses" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Courses</a>
|
|
||||||
<a href="/dashboard/instances" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Instances</a>
|
|
||||||
<a href="/api/v1/user/me" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Profile</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Stats Grid */}
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
||||||
<div class="glass rounded-xl p-6 shadow-lg hover:shadow-xl transition-all duration-300">
|
|
||||||
<div class="w-12 h-12 bg-gradient-to-r from-blue-500 to-blue-600 rounded-lg flex items-center justify-center text-white text-xl mb-4">
|
|
||||||
👥
|
|
||||||
</div>
|
|
||||||
<div class="text-3xl font-bold text-gray-900 mb-2">{stats().total_users}</div>
|
|
||||||
<div class="text-gray-600 font-medium">Total Users</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="glass rounded-xl p-6 shadow-lg hover:shadow-xl transition-all duration-300">
|
|
||||||
<div class="w-12 h-12 bg-gradient-to-r from-green-500 to-green-600 rounded-lg flex items-center justify-center text-white text-xl mb-4">
|
|
||||||
📚
|
|
||||||
</div>
|
|
||||||
<div class="text-3xl font-bold text-gray-900 mb-2">{stats().active_courses}</div>
|
|
||||||
<div class="text-gray-600 font-medium">Active Courses</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="glass rounded-xl p-6 shadow-lg hover:shadow-xl transition-all duration-300">
|
|
||||||
<div class="w-12 h-12 bg-gradient-to-r from-purple-500 to-purple-600 rounded-lg flex items-center justify-center text-white text-xl mb-4">
|
|
||||||
🖥️
|
|
||||||
</div>
|
|
||||||
<div class="text-3xl font-bold text-gray-900 mb-2">{stats().total_instances}</div>
|
|
||||||
<div class="text-gray-600 font-medium">Connected Instances</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="glass rounded-xl p-6 shadow-lg hover:shadow-xl transition-all duration-300">
|
|
||||||
<div class="w-12 h-12 bg-gradient-to-r from-orange-500 to-orange-600 rounded-lg flex items-center justify-center text-white text-xl mb-4">
|
|
||||||
📈
|
|
||||||
</div>
|
|
||||||
<div class="text-3xl font-bold text-gray-900 mb-2">{stats().total_progress}</div>
|
|
||||||
<div class="text-gray-600 font-medium">Learning Progress</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
{/* Recent Courses */}
|
|
||||||
<div class="lg:col-span-2">
|
|
||||||
<div class="glass rounded-2xl p-6 shadow-xl">
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900">Recent Courses</h2>
|
|
||||||
<a href="/dashboard/courses" class="px-4 py-2 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors">
|
|
||||||
Manage Courses
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={loading()} fallback={
|
|
||||||
<Show when={courses().length > 0} fallback={
|
|
||||||
<div class="text-center py-12 text-gray-500">
|
|
||||||
<div class="text-5xl mb-4 opacity-50">📚</div>
|
|
||||||
<div class="text-lg font-semibold mb-2">No courses yet</div>
|
|
||||||
<p>Create your first course to get started!</p>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<For each={courses().slice(0, 5)}>
|
|
||||||
{(course) => (
|
|
||||||
<div class="flex items-center gap-4 p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
|
||||||
<div class="w-12 h-12 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-lg flex items-center justify-center text-white font-bold">
|
|
||||||
{course.title.charAt(0).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="font-medium text-gray-900">{course.title}</div>
|
|
||||||
<div class="text-sm text-gray-600">{course.category} • {course.difficulty} • {course.duration}h</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
class="p-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
|
||||||
onClick={() => window.open(`/api/v1/courses/${course.id}`, '_blank')}
|
|
||||||
title="View"
|
|
||||||
>
|
|
||||||
👁️
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="p-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
|
||||||
onClick={() => window.location.href = `/dashboard/courses?edit=${course.id}`}
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
✏️
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
}>
|
|
||||||
<div class="text-center py-8 text-gray-500">Loading courses...</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Instances */}
|
|
||||||
<div>
|
|
||||||
<div class="glass rounded-2xl p-6 shadow-xl">
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900">Active Instances</h2>
|
|
||||||
<a href="/dashboard/instances" class="text-indigo-600 hover:text-indigo-700 text-sm font-medium">
|
|
||||||
View All
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={loading()} fallback={
|
|
||||||
<Show when={instances().length > 0} fallback={
|
|
||||||
<div class="text-center py-12 text-gray-500">
|
|
||||||
<div class="text-5xl mb-4 opacity-50">🖥️</div>
|
|
||||||
<div class="text-lg font-semibold mb-2">No instances</div>
|
|
||||||
<p>Register your first instance to get started!</p>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<For each={instances().slice(0, 3)}>
|
|
||||||
{(instance) => (
|
|
||||||
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
|
||||||
<div class={`w-2 h-2 rounded-full ${instance.is_active ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="font-medium text-gray-900">{instance.name}</div>
|
|
||||||
<div class="text-sm text-gray-600">{instance.version}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="p-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
|
||||||
onClick={() => window.open(`/api/v1/instances/${instance.id}`, '_blank')}
|
|
||||||
title="View"
|
|
||||||
>
|
|
||||||
🔗
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
}>
|
|
||||||
<div class="text-center py-8 text-gray-500">Loading instances...</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
|
||||||
|
|
||||||
interface Instance {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
api_key: string;
|
|
||||||
is_active: boolean;
|
|
||||||
version: string;
|
|
||||||
created_at: string;
|
|
||||||
last_sync: string;
|
|
||||||
admin_user_id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InstanceManagement = () => {
|
|
||||||
const [instances, setInstances] = createSignal<Instance[]>([]);
|
|
||||||
const [loading, setLoading] = createSignal(true);
|
|
||||||
const [showModal, setShowModal] = createSignal(false);
|
|
||||||
const [editingInstance, setEditingInstance] = createSignal<Instance | null>(null);
|
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [formData, setFormData] = createSignal({
|
|
||||||
name: '',
|
|
||||||
url: '',
|
|
||||||
version: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
await loadInstances();
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadInstances = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/instances');
|
|
||||||
const data = await response.json();
|
|
||||||
setInstances(data.instances || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading instances:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreateModal = () => {
|
|
||||||
setEditingInstance(null);
|
|
||||||
setFormData({
|
|
||||||
name: '',
|
|
||||||
url: '',
|
|
||||||
version: ''
|
|
||||||
});
|
|
||||||
setShowModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditModal = (instance: Instance) => {
|
|
||||||
setEditingInstance(instance);
|
|
||||||
setFormData({
|
|
||||||
name: instance.name,
|
|
||||||
url: instance.url,
|
|
||||||
version: instance.version || ''
|
|
||||||
});
|
|
||||||
setShowModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setShowModal(false);
|
|
||||||
setEditingInstance(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveInstance = async () => {
|
|
||||||
try {
|
|
||||||
const url = editingInstance() ? `/api/v1/instances/${editingInstance()!.id}` : '/api/v1/instances';
|
|
||||||
const method = editingInstance() ? 'PUT' : 'POST';
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify(formData())
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
closeModal();
|
|
||||||
await loadInstances();
|
|
||||||
|
|
||||||
if (!editingInstance()) {
|
|
||||||
const result = await response.json();
|
|
||||||
if (result.api_key) {
|
|
||||||
alert(`🎉 Instance registered successfully!\n\nAPI Key: ${result.api_key}\n\nSave this key securely - it will not be shown again.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert('Error: ' + (error.error || 'Failed to save instance'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving instance:', error);
|
|
||||||
alert('Error: Failed to save instance');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteInstance = async (instanceId: number) => {
|
|
||||||
if (!confirm('Are you sure you want to delete this instance? This action cannot be undone.')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/instances/${instanceId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
await loadInstances();
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert('Error: ' + (error.error || 'Failed to delete instance'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting instance:', error);
|
|
||||||
alert('Error: Failed to delete instance');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const testConnection = async (instance: Instance) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${instance.url}/health`, {
|
|
||||||
method: 'GET',
|
|
||||||
signal: AbortSignal.timeout(5000)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
alert('✅ Connection successful! Instance is responding.');
|
|
||||||
} else {
|
|
||||||
alert('❌ Connection failed. Instance returned an error.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert('❌ Connection failed. Unable to reach the instance.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyApiKey = (apiKey: string, event: MouseEvent) => {
|
|
||||||
navigator.clipboard.writeText(apiKey).then(() => {
|
|
||||||
// Show feedback (you could implement a toast here)
|
|
||||||
const btn = event.target as HTMLButtonElement;
|
|
||||||
const originalText = btn.textContent;
|
|
||||||
btn.textContent = 'Copied!';
|
|
||||||
(btn as HTMLButtonElement).style.background = '#10b981';
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
btn.textContent = originalText;
|
|
||||||
(btn as HTMLButtonElement).style.background = '';
|
|
||||||
}, 2000);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
if (!dateString) return 'Never';
|
|
||||||
const date = new Date(dateString);
|
|
||||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="min-h-screen bg-gradient-to-br from-indigo-500 to-purple-600 p-6">
|
|
||||||
<div class="max-w-7xl mx-auto">
|
|
||||||
{/* Header */}
|
|
||||||
<header class="glass rounded-2xl p-6 mb-8 shadow-xl">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-10 h-10 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center text-white font-bold">
|
|
||||||
T
|
|
||||||
</div>
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Trackeep Controller</h1>
|
|
||||||
</div>
|
|
||||||
<nav class="flex gap-2">
|
|
||||||
<a href="/dashboard" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Dashboard</a>
|
|
||||||
<a href="/dashboard/courses" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Courses</a>
|
|
||||||
<a href="/dashboard/instances" class="px-4 py-2 rounded-lg bg-indigo-500 text-white hover:bg-indigo-600 transition-colors">Instances</a>
|
|
||||||
<a href="/api/v1/user/me" class="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors">Profile</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div class="glass rounded-2xl p-6 shadow-xl">
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
|
||||||
<h2 class="text-2xl font-semibold text-gray-900">Instance Management</h2>
|
|
||||||
<button
|
|
||||||
onClick={openCreateModal}
|
|
||||||
class="px-6 py-3 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span>+</span> Register New Instance
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={loading()} fallback={
|
|
||||||
<Show when={instances().length > 0} fallback={
|
|
||||||
<div class="text-center py-16 text-gray-500">
|
|
||||||
<div class="text-6xl mb-4 opacity-50">🖥️</div>
|
|
||||||
<div class="text-xl font-semibold mb-2">No instances registered</div>
|
|
||||||
<p>Register your first Trackeep instance to get started!</p>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<For each={instances()}>
|
|
||||||
{(instance) => (
|
|
||||||
<div class="bg-white rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden relative">
|
|
||||||
<div class={`absolute top-4 right-4 w-3 h-3 rounded-full ${instance.is_active ? 'bg-green-500' : 'bg-red-500'} ${instance.is_active ? 'animate-pulse' : ''}`}></div>
|
|
||||||
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="flex justify-between items-start mb-4">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">{instance.name}</h3>
|
|
||||||
<a
|
|
||||||
href={instance.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-indigo-600 hover:text-indigo-700 text-sm mb-2 block"
|
|
||||||
>
|
|
||||||
{instance.url}
|
|
||||||
</a>
|
|
||||||
<div class="flex items-center gap-2 text-sm text-gray-600">
|
|
||||||
<div class={`w-2 h-2 rounded-full ${instance.is_active ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
|
||||||
<span>{instance.is_active ? 'Active' : 'Inactive'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
|
||||||
<div>
|
|
||||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Version</div>
|
|
||||||
<div class="text-sm font-medium text-gray-900">{instance.version || 'Unknown'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Created</div>
|
|
||||||
<div class="text-sm font-medium text-gray-900">{formatDate(instance.created_at)}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Last Sync</div>
|
|
||||||
<div class="text-sm font-medium text-gray-900">{formatDate(instance.last_sync)}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs text-gray-500 uppercase tracking-wide">Instance ID</div>
|
|
||||||
<div class="text-sm font-medium text-gray-900">#{instance.id}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-gray-50 rounded-lg p-3 mb-4">
|
|
||||||
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">API Key</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
readonly
|
|
||||||
value={instance.api_key}
|
|
||||||
class="flex-1 text-xs font-mono bg-transparent border-none outline-none text-gray-600"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={(e: MouseEvent) => copyApiKey(instance.api_key, e)}
|
|
||||||
class="px-2 py-1 bg-indigo-500 text-white text-xs rounded hover:bg-indigo-600 transition-colors"
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-2 pt-4 border-t border-gray-200">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-lg font-semibold text-indigo-600">{Math.floor(Math.random() * 100)}</div>
|
|
||||||
<div class="text-xs text-gray-500">Users</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-lg font-semibold text-indigo-600">{Math.floor(Math.random() * 50)}</div>
|
|
||||||
<div class="text-xs text-gray-500">Courses</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-lg font-semibold text-indigo-600">{Math.floor(Math.random() * 1000)}</div>
|
|
||||||
<div class="text-xs text-gray-500">API Calls</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2 mt-4">
|
|
||||||
<button
|
|
||||||
class="flex-1 p-2 text-gray-600 hover:text-indigo-600 hover:bg-gray-50 rounded-lg transition-colors text-sm"
|
|
||||||
onClick={() => testConnection(instance)}
|
|
||||||
title="Test Connection"
|
|
||||||
>
|
|
||||||
🔗
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="flex-1 p-2 text-gray-600 hover:text-indigo-600 hover:bg-gray-50 rounded-lg transition-colors text-sm"
|
|
||||||
onClick={() => openEditModal(instance)}
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
✏️
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="flex-1 p-2 text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors text-sm"
|
|
||||||
onClick={() => deleteInstance(instance.id)}
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
🗑️
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
}>
|
|
||||||
<div class="text-center py-8 text-gray-500">Loading instances...</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Instance Modal */}
|
|
||||||
<Show when={showModal()}>
|
|
||||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div class="bg-white rounded-2xl p-8 max-w-md w-full">
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
|
||||||
<h3 class="text-2xl font-semibold text-gray-900">
|
|
||||||
{editingInstance() ? 'Edit Instance' : 'Register New Instance'}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={closeModal}
|
|
||||||
class="text-gray-500 hover:text-gray-700 text-2xl font-bold"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Instance Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData().name}
|
|
||||||
onInput={(e) => setFormData({ ...formData(), name: e.currentTarget.value })}
|
|
||||||
placeholder="My Trackeep Instance"
|
|
||||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Instance URL *</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={formData().url}
|
|
||||||
onInput={(e) => setFormData({ ...formData(), url: e.currentTarget.value })}
|
|
||||||
placeholder="https://myapp.trackeep.com"
|
|
||||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Version</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData().version}
|
|
||||||
onInput={(e) => setFormData({ ...formData(), version: e.currentTarget.value })}
|
|
||||||
placeholder="1.0.0"
|
|
||||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 justify-end mt-6">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeModal}
|
|
||||||
class="px-6 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={saveInstance}
|
|
||||||
class="px-6 py-3 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors"
|
|
||||||
>
|
|
||||||
{editingInstance() ? 'Update Instance' : 'Register Instance'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { render } from 'solid-js/web';
|
|
||||||
import { Router } from '@solidjs/router';
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
const root = document.getElementById('root');
|
|
||||||
|
|
||||||
if (root) {
|
|
||||||
render(() => (
|
|
||||||
<Router>
|
|
||||||
<App />
|
|
||||||
</Router>
|
|
||||||
), root);
|
|
||||||
} else {
|
|
||||||
console.error('Root element not found');
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
/* Custom styles for Trackeep-inspired UI */
|
|
||||||
.line-clamp-2 {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glassmorphism effects */
|
|
||||||
.glass {
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom animations */
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.5; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-pulse {
|
|
||||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: #f1f1f1;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: #888;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #555;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: {
|
|
||||||
DEFAULT: '#6366f1',
|
|
||||||
dark: '#4f46e5'
|
|
||||||
},
|
|
||||||
secondary: '#8b5cf6',
|
|
||||||
success: '#10b981',
|
|
||||||
warning: '#f59e0b',
|
|
||||||
danger: '#ef4444',
|
|
||||||
dark: '#1f2937',
|
|
||||||
gray: '#6b7280',
|
|
||||||
light: '#f3f4f6',
|
|
||||||
white: '#ffffff'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"lib": ["DOM", "DOM.Iterable", "ES6"],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"strict": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"jsxImportSource": "solid-js"
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import solid from 'vite-plugin-solid';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [solid()],
|
|
||||||
server: {
|
|
||||||
port: 5174,
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:9090',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/auth': {
|
|
||||||
target: 'http://localhost:9090',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/health': {
|
|
||||||
target: 'http://localhost:9090',
|
|
||||||
changeOrigin: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
outDir: '../static',
|
|
||||||
emptyOutDir: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -44,32 +44,38 @@ docker-compose up -d
|
|||||||
The `docker-compose.prod.yml` file uses environment variables with sensible defaults:
|
The `docker-compose.prod.yml` file uses environment variables with sensible defaults:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: '3.8'
|
|
||||||
services:
|
services:
|
||||||
trackeep-frontend:
|
trackeep-frontend:
|
||||||
image: 'ghcr.io/dvorinka/trackeep/frontend:latest'
|
image: 'ghcr.io/dvorinka/trackeep/frontend:latest'
|
||||||
ports:
|
ports:
|
||||||
- '80:80'
|
- "${FRONTEND_PORT:-80}:80"
|
||||||
- '443:443'
|
- "${HTTPS_PORT:-443}:443"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- VITE_DEMO_MODE=${VITE_DEMO_MODE:-false}
|
- VITE_DEMO_MODE=${VITE_DEMO_MODE:-false}
|
||||||
|
- VITE_API_URL=${VITE_API_URL:-http://localhost:8080}
|
||||||
|
- FRONTEND_PORT=${FRONTEND_PORT:-80}
|
||||||
|
- BACKEND_PORT=${BACKEND_PORT:-8080}
|
||||||
depends_on:
|
depends_on:
|
||||||
- trackeep-backend
|
- trackeep-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- trackeep-network
|
- trackeep-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pgrep nginx > /dev/null || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
trackeep-backend:
|
trackeep-backend:
|
||||||
image: 'ghcr.io/dvorinka/trackeep/backend:latest'
|
image: 'ghcr.io/dvorinka/trackeep/backend:latest'
|
||||||
ports:
|
ports:
|
||||||
- '8080:8080'
|
- "${BACKEND_PORT:-8080}:${BACKEND_PORT:-8080}"
|
||||||
environment:
|
environment:
|
||||||
- PORT=${PORT:-8080}
|
- BACKEND_PORT=${BACKEND_PORT:-8080}
|
||||||
|
- FRONTEND_PORT=${FRONTEND_PORT:-80}
|
||||||
- GIN_MODE=${GIN_MODE:-release}
|
- GIN_MODE=${GIN_MODE:-release}
|
||||||
- READ_TIMEOUT=${READ_TIMEOUT:-15s}
|
|
||||||
- WRITE_TIMEOUT=${WRITE_TIMEOUT:-15s}
|
|
||||||
- IDLE_TIMEOUT=${IDLE_TIMEOUT:-60s}
|
|
||||||
- SHUTDOWN_TIMEOUT=${SHUTDOWN_TIMEOUT:-30s}
|
|
||||||
- DB_TYPE=${DB_TYPE:-postgres}
|
- DB_TYPE=${DB_TYPE:-postgres}
|
||||||
- DB_HOST=${DB_HOST:-postgres}
|
- DB_HOST=${DB_HOST:-postgres}
|
||||||
- DB_PORT=${DB_PORT:-5432}
|
- DB_PORT=${DB_PORT:-5432}
|
||||||
@@ -79,19 +85,15 @@ services:
|
|||||||
- DB_SSL_MODE=${DB_SSL_MODE:-disable}
|
- DB_SSL_MODE=${DB_SSL_MODE:-disable}
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
|
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
|
||||||
- UPLOAD_DIR=${UPLOAD_DIR:-./uploads}
|
- UPLOAD_DIR=${UPLOAD_DIR:-./uploads}
|
||||||
- MAX_FILE_SIZE=${MAX_FILE_SIZE:-10485760}
|
- MAX_FILE_SIZE=${MAX_FILE_SIZE:-10485760}
|
||||||
- 'CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-*}'
|
- 'CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-*}'
|
||||||
- VITE_DEMO_MODE=${VITE_DEMO_MODE:-false}
|
- VITE_DEMO_MODE=${VITE_DEMO_MODE:-false}
|
||||||
- SEARCH_API_PROVIDER=${SEARCH_API_PROVIDER:-demo}
|
|
||||||
- SEARCH_RESULTS_LIMIT=${SEARCH_RESULTS_LIMIT:-10}
|
|
||||||
- SEARCH_CACHE_TTL=${SEARCH_CACHE_TTL:-300}
|
|
||||||
- SEARCH_RATE_LIMIT=${SEARCH_RATE_LIMIT:-100}
|
|
||||||
- 'OAUTH_SERVICE_URL=${OAUTH_SERVICE_URL:-http://localhost:9090}'
|
|
||||||
- AUTO_UPDATE_CHECK=${AUTO_UPDATE_CHECK:-false}
|
- AUTO_UPDATE_CHECK=${AUTO_UPDATE_CHECK:-false}
|
||||||
- UPDATE_CHECK_INTERVAL=${UPDATE_CHECK_INTERVAL:-24h}
|
- UPDATE_CHECK_INTERVAL=${UPDATE_CHECK_INTERVAL:-24h}
|
||||||
- PRERELEASE_UPDATES=${PRERELEASE_UPDATES:-false}
|
- PRERELEASE_UPDATES=${PRERELEASE_UPDATES:-false}
|
||||||
|
- DRAGONFLY_ADDR=${DRAGONFLY_ADDR:-dragonfly:6379}
|
||||||
|
- DRAGONFLY_PASSWORD=${DRAGONFLY_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- './data:/data'
|
- './data:/data'
|
||||||
- './uploads:/app/uploads'
|
- './uploads:/app/uploads'
|
||||||
@@ -107,30 +109,55 @@ services:
|
|||||||
- '--no-verbose'
|
- '--no-verbose'
|
||||||
- '--tries=1'
|
- '--tries=1'
|
||||||
- '--spider'
|
- '--spider'
|
||||||
- 'http://localhost:8080/health'
|
- "http://localhost:${BACKEND_PORT:-8080}/health"
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: 'postgres:15-alpine'
|
image: 'postgres:15-alpine'
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5432}:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-trackeep}
|
POSTGRES_DB: ${DB_NAME:-trackeep}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-trackeep}
|
POSTGRES_USER: ${DB_USER:-trackeep}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- 'postgres_data:/var/lib/postgresql/data'
|
- 'postgres_data:/var/lib/postgres/data'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- trackeep-network
|
- trackeep-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-trackeep} -d ${POSTGRES_DB:-trackeep}"]
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-trackeep} -d ${DB_NAME:-trackeep}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
dragonfly:
|
||||||
|
image: ghcr.io/dragonflydb/dragonfly:latest
|
||||||
|
container_name: dragonfly
|
||||||
|
ports:
|
||||||
|
- "${DRAGONFLY_PORT:-6379}:6379"
|
||||||
|
volumes:
|
||||||
|
- dragonfly_data:/data
|
||||||
|
command: dragonfly --requirepass=${DRAGONFLY_PASSWORD} --proactor_threads=2
|
||||||
|
environment:
|
||||||
|
- DRAGONFLY_PASSWORD=${DRAGONFLY_PASSWORD}
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- trackeep-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "redis-cli -a ${DRAGONFLY_PASSWORD} ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data: null
|
postgres_data: null
|
||||||
|
dragonfly_data: null
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
trackeep-network:
|
trackeep-network:
|
||||||
@@ -139,26 +166,34 @@ networks:
|
|||||||
|
|
||||||
### Service Architecture
|
### Service Architecture
|
||||||
|
|
||||||
Trackeep production deployment consists of **3 essential services**:
|
Trackeep production deployment consists of **4 essential services**:
|
||||||
|
|
||||||
#### **🎯 Frontend Service**
|
#### **🎯 Frontend Service**
|
||||||
- **Image**: `ghcr.io/dvorinka/trackeep/frontend:latest`
|
- **Image**: `ghcr.io/dvorinka/trackeep/frontend:latest`
|
||||||
- **Ports**: `80:80`, `443:443`
|
- **Ports**: `${FRONTEND_PORT:-80}:80`, `${HTTPS_PORT:-443}:443`
|
||||||
- **Purpose**: Web interface and user experience
|
- **Purpose**: Web interface and user experience
|
||||||
- **Health**: Depends on backend service
|
- **Health**: nginx process check
|
||||||
|
|
||||||
#### **🔧 Backend Service**
|
#### **🔧 Backend Service**
|
||||||
- **Image**: `ghcr.io/dvorinka/trackeep/backend:latest`
|
- **Image**: `ghcr.io/dvorinka/trackeep/backend:latest`
|
||||||
- **Ports**: `8080:8080`
|
- **Ports**: `${BACKEND_PORT:-8080}:${BACKEND_PORT:-8080}`
|
||||||
- **Purpose**: API server and business logic
|
- **Purpose**: API server and business logic
|
||||||
- **Health**: Built-in health check endpoint
|
- **Health**: HTTP health check endpoint
|
||||||
|
|
||||||
#### **🗄️ Database Service**
|
#### **🗄️ Database Service**
|
||||||
- **Image**: `postgres:15-alpine`
|
- **Image**: `postgres:15-alpine`
|
||||||
|
- **Ports**: `${DB_PORT:-5432}:5432`
|
||||||
- **Purpose**: Data persistence and storage
|
- **Purpose**: Data persistence and storage
|
||||||
- **Health**: PostgreSQL readiness check
|
- **Health**: PostgreSQL readiness check
|
||||||
- **Storage**: Persistent volume for data
|
- **Storage**: Persistent volume for data
|
||||||
|
|
||||||
|
#### **🐉 DragonflyDB Service**
|
||||||
|
- **Image**: `ghcr.io/dragonflydb/dragonfly:latest`
|
||||||
|
- **Ports**: `${DRAGONFLY_PORT:-6379}:6379`
|
||||||
|
- **Purpose**: In-memory caching and session storage
|
||||||
|
- **Health**: Redis-cli ping check
|
||||||
|
- **Storage**: Persistent volume for cache data
|
||||||
|
|
||||||
### Required Environment Variables
|
### Required Environment Variables
|
||||||
|
|
||||||
Create a `.env` file from the provided `.env.example` and configure these required variables:
|
Create a `.env` file from the provided `.env.example` and configure these required variables:
|
||||||
@@ -166,11 +201,12 @@ Create a `.env` file from the provided `.env.example` and configure these requir
|
|||||||
```env
|
```env
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DB_PASSWORD=your_secure_password
|
DB_PASSWORD=your_secure_password
|
||||||
POSTGRES_PASSWORD=your_secure_password
|
|
||||||
|
|
||||||
# Security Configuration
|
# Security Configuration
|
||||||
JWT_SECRET=your_jwt_secret_key
|
JWT_SECRET=your_jwt_secret_key
|
||||||
ENCRYPTION_KEY=your_32_character_encryption_key
|
|
||||||
|
# DragonflyDB Configuration
|
||||||
|
DRAGONFLY_PASSWORD=your_dragonfly_password
|
||||||
```
|
```
|
||||||
|
|
||||||
### AI Services Configuration
|
### AI Services Configuration
|
||||||
@@ -422,9 +458,9 @@ DISABLE_CHINESE_AI=true
|
|||||||
```
|
```
|
||||||
|
|
||||||
4. **Access the application**
|
4. **Access the application**
|
||||||
- Frontend: http://localhost:5173
|
- Frontend: http://localhost:${FRONTEND_PORT:-80}
|
||||||
- Backend API: http://localhost:8080
|
- Backend API: http://localhost:${BACKEND_PORT:-8080}
|
||||||
- Health Check: http://localhost:8080/health
|
- Health Check: http://localhost:${BACKEND_PORT:-8080}/health
|
||||||
|
|
||||||
### Demo Login
|
### Demo Login
|
||||||
- Email: `demo@trackeep.com`
|
- Email: `demo@trackeep.com`
|
||||||
@@ -495,79 +531,44 @@ Key environment variables to configure:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Server Configuration
|
# Server Configuration
|
||||||
PORT=8080
|
FRONTEND_PORT=80
|
||||||
FRONTEND_PORT=5173
|
BACKEND_PORT=8080
|
||||||
GIN_MODE=debug
|
VITE_API_URL=http://localhost:8080
|
||||||
|
GIN_MODE=release
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DB_TYPE=sqlite
|
DB_TYPE=postgres
|
||||||
DB_HOST=localhost
|
DB_HOST=postgres
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_USER=trackeep
|
DB_USER=trackeep
|
||||||
DB_PASSWORD=your_password_here
|
DB_PASSWORD=your_password_here
|
||||||
DB_NAME=trackeep
|
DB_NAME=trackeep
|
||||||
DB_SSL_MODE=disable
|
DB_SSL_MODE=disable
|
||||||
|
|
||||||
# SQLite (for development)
|
# DragonflyDB Configuration
|
||||||
SQLITE_DB_PATH=./trackeep.db
|
DRAGONFLY_ADDR=dragonfly:6379
|
||||||
|
DRAGONFLY_PORT=6379
|
||||||
|
DRAGONFLY_PASSWORD=your_dragonfly_password
|
||||||
|
|
||||||
# JWT Configuration
|
# JWT Configuration
|
||||||
# JWT_SECRET is auto-generated on startup and stored in jwt_secret.key
|
# Generate a secure 64-character hex string using: openssl rand -hex 32
|
||||||
# You can override by setting JWT_SECRET environment variable if needed
|
JWT_SECRET=your_jwt_secret_here_64_hex_characters_long_exactly
|
||||||
JWT_EXPIRES_IN=24h
|
JWT_EXPIRES_IN=24h
|
||||||
|
|
||||||
# Encryption Configuration
|
|
||||||
# ENCRYPTION_KEY is auto-generated on startup and stored in encryption.key
|
|
||||||
# You can override by setting ENCRYPTION_KEY environment variable if needed
|
|
||||||
|
|
||||||
# File Upload Configuration
|
# File Upload Configuration
|
||||||
UPLOAD_DIR=./uploads
|
UPLOAD_DIR=./uploads
|
||||||
MAX_FILE_SIZE=10485760
|
MAX_FILE_SIZE=10485760
|
||||||
|
|
||||||
# CORS Configuration
|
# CORS Configuration
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
CORS_ALLOWED_ORIGINS=*
|
||||||
|
|
||||||
# SMTP Configuration for Password Reset
|
|
||||||
SMTP_HOST=smtp.gmail.com
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USERNAME=your_email@gmail.com
|
|
||||||
SMTP_PASSWORD=your_app_password
|
|
||||||
SMTP_FROM_EMAIL=your_email@gmail.com
|
|
||||||
SMTP_FROM_NAME=Trackeep
|
|
||||||
|
|
||||||
# Demo Mode Configuration
|
# Demo Mode Configuration
|
||||||
VITE_DEMO_MODE=false
|
VITE_DEMO_MODE=false
|
||||||
|
|
||||||
# AI Services (All Optional)
|
# Auto Update Configuration
|
||||||
# Chinese AI Services (Budget-friendly)
|
AUTO_UPDATE_CHECK=false
|
||||||
LONGCAT_API_KEY=your_longcat_api_key_here
|
UPDATE_CHECK_INTERVAL=24h
|
||||||
LONGCAT_BASE_URL=https://api.longcat.chat
|
PRERELEASE_UPDATES=false
|
||||||
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
|
||||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
|
||||||
|
|
||||||
# Western AI Services
|
|
||||||
MISTRAL_API_KEY=your_mistral_api_key_here
|
|
||||||
MISTRAL_MODEL=mistral-small-latest
|
|
||||||
GROK_API_KEY=your_grok_api_key_here
|
|
||||||
OPENAI_API_KEY=your_openai_api_key_here
|
|
||||||
OPENAI_BASE_URL=https://api.openai.com
|
|
||||||
|
|
||||||
# Local AI (Complete Privacy)
|
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
|
||||||
|
|
||||||
# AI Control (Disable what you don't want)
|
|
||||||
DISABLE_AI=false
|
|
||||||
DISABLE_LONGCAT=false
|
|
||||||
DISABLE_DEEPSEEK=false
|
|
||||||
DISABLE_MISTRAL=false
|
|
||||||
DISABLE_GROK=false
|
|
||||||
DISABLE_OPENAI=false
|
|
||||||
DISABLE_CHINESE_AI=false
|
|
||||||
DISABLE_ALL_CLOUD_AI=false
|
|
||||||
|
|
||||||
# OAuth Configuration
|
|
||||||
GITHUB_CLIENT_ID=your_github_client_id
|
|
||||||
GITHUB_CLIENT_SECRET=your_github_client_secret
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM golang:1.24-alpine AS builder
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -27,6 +27,9 @@ WORKDIR /root/
|
|||||||
# Copy the binary from builder stage
|
# Copy the binary from builder stage
|
||||||
COPY --from=builder /app/main .
|
COPY --from=builder /app/main .
|
||||||
|
|
||||||
|
# Copy migrations directory
|
||||||
|
COPY --from=builder /app/migrations ./migrations
|
||||||
|
|
||||||
# Create necessary directories
|
# Create necessary directories
|
||||||
RUN mkdir -p /app/uploads /data
|
RUN mkdir -p /app/uploads /data
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/trackeep/backend/migrations"
|
||||||
|
"go.uber.org/zap"
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var DB *gorm.DB
|
var DB *gorm.DB
|
||||||
@@ -26,12 +26,20 @@ func getJWTSecret() string {
|
|||||||
|
|
||||||
// InitDatabase initializes the database connection
|
// InitDatabase initializes the database connection
|
||||||
func InitDatabase() {
|
func InitDatabase() {
|
||||||
|
// Initialize logger first
|
||||||
|
InitLogger()
|
||||||
|
logger := GetLogger()
|
||||||
|
|
||||||
|
// Check if demo mode is enabled
|
||||||
|
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||||
|
logger.Info("Demo mode enabled - skipping database initialization")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Configure GORM logger
|
// Configure GORM
|
||||||
gormConfig := &gorm.Config{
|
gormConfig := &gorm.Config{}
|
||||||
Logger: logger.Default.LogMode(logger.Info),
|
|
||||||
}
|
|
||||||
|
|
||||||
dbType := os.Getenv("DB_TYPE")
|
dbType := os.Getenv("DB_TYPE")
|
||||||
if dbType == "" {
|
if dbType == "" {
|
||||||
@@ -49,19 +57,28 @@ func InitDatabase() {
|
|||||||
os.Getenv("DB_SSL_MODE"),
|
os.Getenv("DB_SSL_MODE"),
|
||||||
)
|
)
|
||||||
DB, err = gorm.Open(postgres.Open(dsn), gormConfig)
|
DB, err = gorm.Open(postgres.Open(dsn), gormConfig)
|
||||||
log.Println("Using PostgreSQL database")
|
logger.Info("Using PostgreSQL database")
|
||||||
default:
|
default:
|
||||||
log.Fatal("Unsupported database type: " + dbType)
|
logger.Fatal("Unsupported database type", zap.String("type", dbType))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Failed to connect to database:", err)
|
logger.Fatal("Failed to connect to database", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Database connected successfully")
|
logger.Info("Database connected successfully")
|
||||||
|
|
||||||
|
// Run database migrations
|
||||||
|
if err := migrations.RunMigrations(); err != nil {
|
||||||
|
logger.Fatal("Failed to run database migrations", zap.Error(err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDB returns the database instance
|
// GetDB returns the database instance
|
||||||
func GetDB() *gorm.DB {
|
func GetDB() *gorm.DB {
|
||||||
|
// In demo mode, return nil since no database is available
|
||||||
|
if os.Getenv("VITE_DEMO_MODE") == "true" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return DB
|
return DB
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Logger *zap.Logger
|
||||||
|
|
||||||
|
// InitLogger initializes the Zap logger
|
||||||
|
func InitLogger() {
|
||||||
|
// Get log level from environment
|
||||||
|
logLevel := os.Getenv("LOG_LEVEL")
|
||||||
|
if logLevel == "" {
|
||||||
|
logLevel = "info"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse log level
|
||||||
|
var level zapcore.Level
|
||||||
|
switch logLevel {
|
||||||
|
case "debug":
|
||||||
|
level = zapcore.DebugLevel
|
||||||
|
case "info":
|
||||||
|
level = zapcore.InfoLevel
|
||||||
|
case "warn":
|
||||||
|
level = zapcore.WarnLevel
|
||||||
|
case "error":
|
||||||
|
level = zapcore.ErrorLevel
|
||||||
|
default:
|
||||||
|
level = zapcore.InfoLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're in production mode
|
||||||
|
isProduction := os.Getenv("GIN_MODE") == "release"
|
||||||
|
|
||||||
|
// Configure encoder
|
||||||
|
var encoder zapcore.Encoder
|
||||||
|
encoderConfig := zap.NewProductionEncoderConfig()
|
||||||
|
encoderConfig.TimeKey = "timestamp"
|
||||||
|
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||||
|
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
|
||||||
|
|
||||||
|
if isProduction {
|
||||||
|
encoder = zapcore.NewJSONEncoder(encoderConfig)
|
||||||
|
} else {
|
||||||
|
encoder = zapcore.NewConsoleEncoder(encoderConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure output
|
||||||
|
writeSyncer := zapcore.AddSync(os.Stdout)
|
||||||
|
|
||||||
|
// Create core
|
||||||
|
core := zapcore.NewCore(encoder, writeSyncer, level)
|
||||||
|
|
||||||
|
// Create logger
|
||||||
|
Logger = zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel))
|
||||||
|
|
||||||
|
// Replace global logger
|
||||||
|
zap.ReplaceGlobals(Logger)
|
||||||
|
|
||||||
|
Logger.Info("Logger initialized",
|
||||||
|
zap.String("level", logLevel),
|
||||||
|
zap.Bool("production", isProduction),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogger returns the configured logger instance
|
||||||
|
func GetLogger() *zap.Logger {
|
||||||
|
if Logger == nil {
|
||||||
|
// Fallback to default logger if not initialized
|
||||||
|
logger, _ := zap.NewProduction()
|
||||||
|
return logger
|
||||||
|
}
|
||||||
|
return Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncLogger flushes any buffered log entries
|
||||||
|
func SyncLogger() {
|
||||||
|
if Logger != nil {
|
||||||
|
_ = Logger.Sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package examples
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/trackeep/backend/internal/db"
|
||||||
|
"github.com/trackeep/backend/internal/db/sqlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserServiceExample demonstrates how to use sqlc with typed queries
|
||||||
|
type UserServiceExample struct {
|
||||||
|
db *db.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserServiceExample creates a new user service example
|
||||||
|
func NewUserServiceExample(database *db.DB) *UserServiceExample {
|
||||||
|
return &UserServiceExample{
|
||||||
|
db: database,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUserExample shows how to create a user with typed queries
|
||||||
|
func (s *UserServiceExample) CreateUserExample(ctx context.Context, email, passwordHash, firstName, lastName string) (sqlc.User, error) {
|
||||||
|
// Use typed query - no SQL strings, no reflection
|
||||||
|
user, err := s.db.CreateUser(ctx, sqlc.CreateUserParams{
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
FirstName: &firstName,
|
||||||
|
LastName: &lastName,
|
||||||
|
IsActive: &[]bool{true}[0],
|
||||||
|
IsVerified: &[]bool{false}[0],
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return sqlc.User{}, fmt.Errorf("failed to create user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert CreateUserRow to User
|
||||||
|
return sqlc.User{
|
||||||
|
ID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
FirstName: user.FirstName,
|
||||||
|
LastName: user.LastName,
|
||||||
|
AvatarUrl: user.AvatarUrl,
|
||||||
|
IsActive: user.IsActive,
|
||||||
|
IsVerified: user.IsVerified,
|
||||||
|
LastLogin: user.LastLogin,
|
||||||
|
CreatedAt: user.CreatedAt,
|
||||||
|
UpdatedAt: user.UpdatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserExample shows how to get a user by ID
|
||||||
|
func (s *UserServiceExample) GetUserExample(ctx context.Context, userID pgtype.UUID) (sqlc.User, error) {
|
||||||
|
// Use typed query
|
||||||
|
user, err := s.db.GetUserByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return sqlc.User{}, fmt.Errorf("failed to get user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert GetUserByIDRow to User
|
||||||
|
return sqlc.User{
|
||||||
|
ID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
FirstName: user.FirstName,
|
||||||
|
LastName: user.LastName,
|
||||||
|
AvatarUrl: user.AvatarUrl,
|
||||||
|
IsActive: user.IsActive,
|
||||||
|
IsVerified: user.IsVerified,
|
||||||
|
LastLogin: user.LastLogin,
|
||||||
|
CreatedAt: user.CreatedAt,
|
||||||
|
UpdatedAt: user.UpdatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchUsersExample shows how to search users with pagination
|
||||||
|
func (s *UserServiceExample) SearchUsersExample(ctx context.Context, limit, offset int32) ([]sqlc.User, error) {
|
||||||
|
// Use typed query with parameters
|
||||||
|
users, err := s.db.ListUsers(ctx, sqlc.ListUsersParams{
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list users: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert ListUsersRow to User
|
||||||
|
result := make([]sqlc.User, len(users))
|
||||||
|
for i, user := range users {
|
||||||
|
result[i] = sqlc.User{
|
||||||
|
ID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
FirstName: user.FirstName,
|
||||||
|
LastName: user.LastName,
|
||||||
|
AvatarUrl: user.AvatarUrl,
|
||||||
|
IsActive: user.IsActive,
|
||||||
|
IsVerified: user.IsVerified,
|
||||||
|
LastLogin: user.LastLogin,
|
||||||
|
CreatedAt: user.CreatedAt,
|
||||||
|
UpdatedAt: user.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransactionExample shows how to use transactions with sqlc
|
||||||
|
func (s *UserServiceExample) TransactionExample(ctx context.Context, email, passwordHash string) (sqlc.User, error) {
|
||||||
|
// Start transaction
|
||||||
|
tx, err := s.db.BeginTx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return sqlc.User{}, fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create user within transaction
|
||||||
|
user, err := tx.CreateUser(ctx, sqlc.CreateUserParams{
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
IsActive: &[]bool{true}[0],
|
||||||
|
IsVerified: &[]bool{false}[0],
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return sqlc.User{}, fmt.Errorf("failed to create user in transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login within transaction
|
||||||
|
err = tx.UpdateLastLogin(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return sqlc.User{}, fmt.Errorf("failed to update last login: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit transaction
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return sqlc.User{}, fmt.Errorf("failed to commit transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert CreateUserRow to User
|
||||||
|
return sqlc.User{
|
||||||
|
ID: user.ID,
|
||||||
|
Email: user.Email,
|
||||||
|
FirstName: user.FirstName,
|
||||||
|
LastName: user.LastName,
|
||||||
|
AvatarUrl: user.AvatarUrl,
|
||||||
|
IsActive: user.IsActive,
|
||||||
|
IsVerified: user.IsVerified,
|
||||||
|
LastLogin: user.LastLogin,
|
||||||
|
CreatedAt: user.CreatedAt,
|
||||||
|
UpdatedAt: user.UpdatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
module github.com/trackeep/backend
|
module github.com/trackeep/backend
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.11.0
|
github.com/PuerkitoBio/goquery v1.11.0
|
||||||
@@ -12,8 +12,9 @@ require (
|
|||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
golang.org/x/crypto v0.47.0
|
github.com/pressly/goose/v3 v3.27.0
|
||||||
golang.org/x/net v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
|
golang.org/x/net v0.50.0
|
||||||
golang.org/x/oauth2 v0.17.0
|
golang.org/x/oauth2 v0.17.0
|
||||||
gorm.io/driver/postgres v1.5.4
|
gorm.io/driver/postgres v1.5.4
|
||||||
gorm.io/gorm v1.25.5
|
gorm.io/gorm v1.25.5
|
||||||
@@ -27,7 +28,7 @@ require (
|
|||||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||||
github.com/boombuler/barcode v1.0.1 // indirect
|
github.com/boombuler/barcode v1.0.1 // indirect
|
||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998 // indirect
|
github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998 // indirect
|
||||||
github.com/chromedp/sysutil v1.0.0 // indirect
|
github.com/chromedp/sysutil v1.0.0 // indirect
|
||||||
@@ -45,8 +46,9 @@ require (
|
|||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.4.3 // indirect
|
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
@@ -55,21 +57,27 @@ require (
|
|||||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/leodido/go-urn v1.2.4 // indirect
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
|
github.com/lib/pq v1.11.2 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/nlnwa/whatwg-url v0.6.2 // indirect
|
github.com/nlnwa/whatwg-url v0.6.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
|
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
|
||||||
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
github.com/temoto/robotstxt v1.1.2 // indirect
|
github.com/temoto/robotstxt v1.1.2 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
go.uber.org/zap v1.27.1 // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
google.golang.org/appengine v1.6.8 // indirect
|
google.golang.org/appengine v1.6.8 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl
|
|||||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||||
@@ -34,6 +34,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||||
@@ -78,14 +80,18 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
|
|||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
|
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||||
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
@@ -109,15 +115,21 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL
|
|||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||||
|
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
|
||||||
|
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
|
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
|
github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
|
||||||
github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
|
github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
|
||||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||||
@@ -134,10 +146,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
|
github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM=
|
||||||
|
github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
|
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
|
||||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
||||||
|
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||||
|
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -147,8 +165,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
|
||||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
|
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
|
||||||
github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
|
github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
@@ -156,6 +175,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2
|
|||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
|
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
@@ -166,8 +189,10 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
|
|||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||||
|
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
@@ -183,8 +208,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
|||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ=
|
golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ=
|
||||||
golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
|
golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -194,6 +219,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -208,8 +235,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -230,8 +257,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
@@ -244,8 +271,8 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs
|
|||||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
@@ -260,4 +287,12 @@ gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
|
|||||||
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
|
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
|
||||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
|
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||||
|
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||||
|
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
|||||||
@@ -11,16 +11,16 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"github.com/trackeep/backend/config"
|
"github.com/trackeep/backend/config"
|
||||||
"github.com/trackeep/backend/models"
|
"github.com/trackeep/backend/models"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
@@ -60,20 +60,51 @@ type PasswordResetCode struct {
|
|||||||
|
|
||||||
// JWT Claims structure
|
// JWT Claims structure
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID uint `json:"user_id"`
|
UserID uint `json:"user_id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
GitHubID int `json:"github_id,omitempty"`
|
||||||
|
AccessToken string `json:"access_token,omitempty"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getDurationEnv parses duration from environment variable with fallback
|
||||||
|
func getDurationEnv(key string, defaultValue time.Duration) time.Duration {
|
||||||
|
value := os.Getenv(key)
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
seconds, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
duration, err := time.ParseDuration(value)
|
||||||
|
if err != nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return duration
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Duration(seconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateJWT creates a new JWT token for a user
|
// GenerateJWT creates a new JWT token for a user
|
||||||
func GenerateJWT(user models.User) (string, error) {
|
func GenerateJWT(user models.User) (string, error) {
|
||||||
|
return generateJWT(user, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateJWTWithGitHubAccessToken(user models.User, accessToken string) (string, error) {
|
||||||
|
return generateJWT(user, accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateJWT(user models.User, accessToken string) (string, error) {
|
||||||
claims := &Claims{
|
claims := &Claims{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
|
GitHubID: user.GitHubID,
|
||||||
|
AccessToken: accessToken,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(getDurationEnv("JWT_EXPIRES_IN", 24*time.Hour))),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
Issuer: "trackeep",
|
Issuer: "trackeep",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"github.com/trackeep/backend/config"
|
"github.com/trackeep/backend/config"
|
||||||
"github.com/trackeep/backend/models"
|
"github.com/trackeep/backend/models"
|
||||||
@@ -53,6 +52,8 @@ type GitHubRepo struct {
|
|||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
|
CloneURL string `json:"clone_url"`
|
||||||
|
Private bool `json:"private"`
|
||||||
Stargazers int `json:"stargazers_count"`
|
Stargazers int `json:"stargazers_count"`
|
||||||
Forks int `json:"forks_count"`
|
Forks int `json:"forks_count"`
|
||||||
Watchers int `json:"watchers_count"`
|
Watchers int `json:"watchers_count"`
|
||||||
@@ -66,6 +67,14 @@ type GitHubRepo struct {
|
|||||||
|
|
||||||
// GitHubLogin initiates the GitHub OAuth flow
|
// GitHubLogin initiates the GitHub OAuth flow
|
||||||
func GitHubLogin(c *gin.Context) {
|
func GitHubLogin(c *gin.Context) {
|
||||||
|
frontendRedirect := resolveFrontendRedirectURL(c.Request)
|
||||||
|
callbackURL := buildOAuthCallbackURL(c.Request, frontendRedirect)
|
||||||
|
if oauthServiceURL := getOAuthServiceURL(); oauthServiceURL != "" && callbackURL != "" {
|
||||||
|
redirectURL := fmt.Sprintf("%s/auth/github?redirect_uri=%s", oauthServiceURL, url.QueryEscape(callbackURL))
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if githubOAuthConfig == nil {
|
if githubOAuthConfig == nil {
|
||||||
initGitHubOAuth()
|
initGitHubOAuth()
|
||||||
}
|
}
|
||||||
@@ -119,55 +128,35 @@ func GitHubCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get or create user in database
|
// Get or create user in database
|
||||||
db := c.MustGet("db").(*gorm.DB)
|
db := config.GetDB()
|
||||||
var existingUser models.User
|
if db == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||||
// First try to find by GitHub ID
|
return
|
||||||
err = db.Where("github_id = ?", user.ID).First(&existingUser).Error
|
}
|
||||||
|
existingUser, err := upsertCentralizedOAuthUser(db, centralizedOAuthUser{
|
||||||
|
GitHubID: user.ID,
|
||||||
|
Username: user.Login,
|
||||||
|
Email: user.Email,
|
||||||
|
Name: user.Name,
|
||||||
|
AvatarURL: user.AvatarURL,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If not found by GitHub ID, try by email
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to synchronize user"})
|
||||||
err = db.Where("email = ?", user.Email).First(&existingUser).Error
|
return
|
||||||
if err != nil {
|
|
||||||
// Create new user
|
|
||||||
newUser := models.User{
|
|
||||||
Username: user.Login,
|
|
||||||
Email: user.Email,
|
|
||||||
FullName: user.Name,
|
|
||||||
GitHubID: user.ID,
|
|
||||||
AvatarURL: user.AvatarURL,
|
|
||||||
Provider: "github",
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Create(&newUser).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
existingUser = newUser
|
|
||||||
} else {
|
|
||||||
// Update existing user with GitHub info
|
|
||||||
existingUser.GitHubID = user.ID
|
|
||||||
existingUser.AvatarURL = user.AvatarURL
|
|
||||||
existingUser.Provider = "github"
|
|
||||||
db.Save(&existingUser)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token
|
tokenString, err := GenerateJWTWithGitHubAccessToken(*existingUser, token.AccessToken)
|
||||||
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
||||||
"user_id": existingUser.ID,
|
|
||||||
"email": existingUser.Email,
|
|
||||||
"username": existingUser.Username,
|
|
||||||
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
|
|
||||||
})
|
|
||||||
|
|
||||||
tokenString, err := jwtToken.SignedString([]byte(config.JWTSecret))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to frontend with token
|
// Redirect to frontend with token
|
||||||
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", os.Getenv("FRONTEND_URL"), tokenString)
|
redirectURL := buildFrontendCallbackRedirectURL("", tokenString)
|
||||||
|
if redirectURL == "" {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Frontend redirect URL not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,69 +248,41 @@ func HandleOAuthCallback(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the JWT from the OAuth service
|
validationResponse, err := validateCentralizedOAuthToken(c.Request.Context(), token)
|
||||||
claims := jwt.MapClaims{}
|
if err != nil {
|
||||||
parsedToken, err := jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) {
|
|
||||||
// Use the OAuth service's JWT secret (should be shared)
|
|
||||||
return []byte(os.Getenv("OAUTH_JWT_SECRET")), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil || !parsedToken.Valid {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid OAuth token"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid OAuth token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract user information from OAuth service
|
|
||||||
username, _ := claims["username"].(string)
|
|
||||||
email, _ := claims["email"].(string)
|
|
||||||
githubID, _ := claims["github_id"]
|
|
||||||
accessToken, _ := claims["access_token"].(string)
|
|
||||||
|
|
||||||
// Get database
|
// Get database
|
||||||
db := c.MustGet("db").(*gorm.DB)
|
db := config.GetDB()
|
||||||
|
if db == nil {
|
||||||
// Find or create user in local database
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||||
var user models.User
|
return
|
||||||
err = db.Where("email = ?", email).First(&user).Error
|
}
|
||||||
|
localUser, err := upsertCentralizedOAuthUser(db, validationResponse.User)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Create new user
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to synchronize user"})
|
||||||
newUser := models.User{
|
return
|
||||||
Username: username,
|
|
||||||
Email: email,
|
|
||||||
GitHubID: int(githubID.(float64)), // JWT numbers are float64
|
|
||||||
Provider: "github",
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Create(&newUser).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user = newUser
|
|
||||||
} else {
|
|
||||||
// Update existing user with GitHub info
|
|
||||||
user.GitHubID = int(githubID.(float64))
|
|
||||||
user.Provider = "github"
|
|
||||||
db.Save(&user)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Trackeep JWT token
|
claims, err := parseOAuthTokenClaimsUnverified(token)
|
||||||
trackeepToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
if err != nil {
|
||||||
"user_id": user.ID,
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid OAuth token claims"})
|
||||||
"email": user.Email,
|
return
|
||||||
"username": user.Username,
|
}
|
||||||
"github_id": user.GitHubID,
|
|
||||||
"access_token": accessToken, // Pass through the GitHub access token
|
|
||||||
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
|
|
||||||
})
|
|
||||||
|
|
||||||
trackeepTokenString, err := trackeepToken.SignedString([]byte(os.Getenv("JWT_SECRET")))
|
trackeepTokenString, err := GenerateJWTWithGitHubAccessToken(*localUser, getAccessTokenFromOAuthClaims(claims))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to frontend with Trackeep token
|
redirectURL := buildFrontendCallbackRedirectURL(c.Query("frontend_redirect"), trackeepTokenString)
|
||||||
redirectURL := fmt.Sprintf("%s/auth/callback?token=%s", os.Getenv("FRONTEND_URL"), trackeepTokenString)
|
if redirectURL == "" {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Frontend redirect URL not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +304,11 @@ func GetCurrentUserWithGitHub(c *gin.Context) {
|
|||||||
func GetGitHubRepos(c *gin.Context) {
|
func GetGitHubRepos(c *gin.Context) {
|
||||||
userID := c.GetUint("user_id")
|
userID := c.GetUint("user_id")
|
||||||
|
|
||||||
db := c.MustGet("db").(*gorm.DB)
|
db := config.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
var user models.User
|
var user models.User
|
||||||
if err := db.First(&user, userID).Error; err != nil {
|
if err := db.First(&user, userID).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||||
@@ -368,26 +333,20 @@ func GetGitHubRepos(c *gin.Context) {
|
|||||||
tokenString = authHeader[7:]
|
tokenString = authHeader[7:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the JWT to get the GitHub access token from the centralized OAuth service
|
claims, err := ValidateJWT(tokenString)
|
||||||
claims := jwt.MapClaims{}
|
if err != nil {
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) {
|
|
||||||
return []byte(os.Getenv("JWT_SECRET")), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil || !token.Valid {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract GitHub access token from the OAuth service JWT
|
githubAccessToken := strings.TrimSpace(claims.AccessToken)
|
||||||
githubAccessToken, ok := claims["access_token"]
|
if githubAccessToken == "" {
|
||||||
if !ok {
|
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub access token not found. Please reconnect GitHub."})
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub access token not found"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch repositories using the GitHub access token
|
// Fetch repositories using the GitHub access token
|
||||||
repos, err := fetchGitHubRepos(githubAccessToken.(string))
|
repos, err := fetchGitHubRepos(githubAccessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repos: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repos: " + err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,944 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/trackeep/backend/config"
|
||||||
|
"github.com/trackeep/backend/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var githubRepoFullNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
|
||||||
|
|
||||||
|
type gitHubInstallationReposResponse struct {
|
||||||
|
Repositories []GitHubRepo `json:"repositories"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitHubAppInstallationDetails struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Account struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"account"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitHubInstallationTokenResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitHubBackupRequest struct {
|
||||||
|
Repositories []string `json:"repositories"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type gitHubBackupResult struct {
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LocalPath string `json:"local_path"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGitHubAppStatus returns install/configuration status for GitHub App integration.
|
||||||
|
func GetGitHubAppStatus(c *gin.Context) {
|
||||||
|
userID := getGitHubRequestUserID(c)
|
||||||
|
if userID == 0 {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := config.GetDB()
|
||||||
|
response := gin.H{
|
||||||
|
"app_slug": getGitHubAppSlug(),
|
||||||
|
"install_enabled": isGitHubAppInstallEnabled(),
|
||||||
|
"credentials_configured": hasGitHubAppCredentials(),
|
||||||
|
"installed": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if db == nil {
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
installation, err := getUserGitHubInstallation(db, userID)
|
||||||
|
if err == nil {
|
||||||
|
response["installed"] = true
|
||||||
|
response["installation"] = installation
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGitHubAppInstallURL creates a one-time state and returns an install URL for the configured GitHub App.
|
||||||
|
func GetGitHubAppInstallURL(c *gin.Context) {
|
||||||
|
userID := getGitHubRequestUserID(c)
|
||||||
|
if userID == 0 {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isGitHubAppInstallEnabled() {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub App slug is not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := config.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := generateRandomString(24)
|
||||||
|
expiresAt := time.Now().Add(15 * time.Minute)
|
||||||
|
stateRecord := models.GitHubAppInstallState{
|
||||||
|
UserID: userID,
|
||||||
|
State: state,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
if err := db.Create(&stateRecord).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create install state"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
installURL := fmt.Sprintf(
|
||||||
|
"https://github.com/apps/%s/installations/new?state=%s",
|
||||||
|
url.PathEscape(getGitHubAppSlug()),
|
||||||
|
url.QueryEscape(state),
|
||||||
|
)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"install_url": installURL,
|
||||||
|
"expires_at": expiresAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GitHubAppInstallCallback handles GitHub App setup callback and links installation to a Trackeep user.
|
||||||
|
func GitHubAppInstallCallback(c *gin.Context) {
|
||||||
|
state := strings.TrimSpace(c.Query("state"))
|
||||||
|
installationRaw := strings.TrimSpace(c.Query("installation_id"))
|
||||||
|
setupAction := strings.TrimSpace(c.Query("setup_action"))
|
||||||
|
|
||||||
|
if state == "" || installationRaw == "" {
|
||||||
|
redirectToGitHubIntegrationPage(c, false, 0, setupAction, "missing_state_or_installation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
installationID, err := strconv.ParseInt(installationRaw, 10, 64)
|
||||||
|
if err != nil || installationID <= 0 {
|
||||||
|
redirectToGitHubIntegrationPage(c, false, 0, setupAction, "invalid_installation_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := config.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "database_unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var stateRecord models.GitHubAppInstallState
|
||||||
|
if err := db.Where("state = ?", state).First(&stateRecord).Error; err != nil {
|
||||||
|
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "invalid_state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if stateRecord.UsedAt != nil {
|
||||||
|
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "state_already_used")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().After(stateRecord.ExpiresAt) {
|
||||||
|
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "state_expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountLogin := ""
|
||||||
|
accountType := ""
|
||||||
|
lastValidated := (*time.Time)(nil)
|
||||||
|
if hasGitHubAppCredentials() {
|
||||||
|
details, detailsErr := fetchGitHubAppInstallationDetails(c.Request.Context(), installationID)
|
||||||
|
if detailsErr == nil && details != nil {
|
||||||
|
accountLogin = details.Account.Login
|
||||||
|
accountType = details.Account.Type
|
||||||
|
now := time.Now()
|
||||||
|
lastValidated = &now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var installation models.GitHubAppInstallation
|
||||||
|
lookupErr := db.Where("installation_id = ?", installationID).First(&installation).Error
|
||||||
|
switch {
|
||||||
|
case errors.Is(lookupErr, gorm.ErrRecordNotFound):
|
||||||
|
installation = models.GitHubAppInstallation{
|
||||||
|
UserID: stateRecord.UserID,
|
||||||
|
InstallationID: installationID,
|
||||||
|
AppSlug: getGitHubAppSlug(),
|
||||||
|
AccountLogin: accountLogin,
|
||||||
|
AccountType: accountType,
|
||||||
|
LastValidated: lastValidated,
|
||||||
|
}
|
||||||
|
if err := db.Create(&installation).Error; err != nil {
|
||||||
|
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "failed_to_store_installation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case lookupErr != nil:
|
||||||
|
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "installation_lookup_failed")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"user_id": stateRecord.UserID,
|
||||||
|
"app_slug": getGitHubAppSlug(),
|
||||||
|
"account_login": accountLogin,
|
||||||
|
"account_type": accountType,
|
||||||
|
}
|
||||||
|
if lastValidated != nil {
|
||||||
|
updates["last_validated"] = lastValidated
|
||||||
|
}
|
||||||
|
if err := db.Model(&installation).Updates(updates).Error; err != nil {
|
||||||
|
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "failed_to_update_installation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usedAt := time.Now()
|
||||||
|
if err := db.Model(&stateRecord).Update("used_at", usedAt).Error; err != nil {
|
||||||
|
redirectToGitHubIntegrationPage(c, false, installationID, setupAction, "failed_to_finalize_state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectToGitHubIntegrationPage(c, true, installationID, setupAction, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGitHubAppRepos returns repositories available through the user's GitHub App installation.
|
||||||
|
func GetGitHubAppRepos(c *gin.Context) {
|
||||||
|
userID := getGitHubRequestUserID(c)
|
||||||
|
if userID == 0 {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := config.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
installation, err := getUserGitHubInstallation(db, userID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "GitHub App is not installed for this user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, _, err := createGitHubInstallationAccessToken(c.Request.Context(), installation.InstallationID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to create GitHub App installation token: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repos, err := fetchGitHubInstallationRepos(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch installation repos: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"source": "github_app",
|
||||||
|
"installation_id": installation.InstallationID,
|
||||||
|
"repos": repos,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGitHubBackups lists local GitHub repository backups for the authenticated user.
|
||||||
|
func GetGitHubBackups(c *gin.Context) {
|
||||||
|
userID := getGitHubRequestUserID(c)
|
||||||
|
if userID == 0 {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := config.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var backups []models.GitHubRepoBackup
|
||||||
|
if err := db.Where("user_id = ?", userID).Order("updated_at DESC").Find(&backups).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch repository backups"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"backup_root": getGitHubBackupRoot(),
|
||||||
|
"backups": backups,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupGitHubRepositories clones or updates selected repositories in local mirror storage.
|
||||||
|
func BackupGitHubRepositories(c *gin.Context) {
|
||||||
|
userID := getGitHubRequestUserID(c)
|
||||||
|
if userID == 0 {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := config.GetDB()
|
||||||
|
if db == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req gitHubBackupRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Repositories) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "At least one repository must be provided"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, source, installationID, err := resolveGitHubBackupToken(c, db, userID, req.Source)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(getGitHubBackupRoot(), 0755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to prepare backup directory"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
knownRepos := make(map[string]GitHubRepo)
|
||||||
|
switch source {
|
||||||
|
case "oauth":
|
||||||
|
repos, reposErr := fetchGitHubRepos(accessToken)
|
||||||
|
if reposErr == nil {
|
||||||
|
for _, repo := range repos {
|
||||||
|
knownRepos[strings.ToLower(repo.FullName)] = repo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "github_app":
|
||||||
|
repos, reposErr := fetchGitHubInstallationRepos(accessToken)
|
||||||
|
if reposErr == nil {
|
||||||
|
for _, repo := range repos {
|
||||||
|
knownRepos[strings.ToLower(repo.FullName)] = repo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]gitHubBackupResult, 0, len(req.Repositories))
|
||||||
|
successCount := 0
|
||||||
|
failedCount := 0
|
||||||
|
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, rawRepo := range req.Repositories {
|
||||||
|
repoFullName, normalizeErr := normalizeGitHubRepoFullName(rawRepo)
|
||||||
|
if normalizeErr != nil {
|
||||||
|
failedCount++
|
||||||
|
results = append(results, gitHubBackupResult{
|
||||||
|
Repository: strings.TrimSpace(rawRepo),
|
||||||
|
Status: "error",
|
||||||
|
Source: source,
|
||||||
|
Error: normalizeErr.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[repoFullName]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[repoFullName] = struct{}{}
|
||||||
|
|
||||||
|
repoInfo, hasInfo := knownRepos[strings.ToLower(repoFullName)]
|
||||||
|
if !hasInfo {
|
||||||
|
repoDetails, fetchErr := fetchGitHubRepoByFullName(accessToken, repoFullName)
|
||||||
|
if fetchErr == nil && repoDetails != nil {
|
||||||
|
repoInfo = *repoDetails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if repoInfo.FullName == "" {
|
||||||
|
repoInfo.FullName = repoFullName
|
||||||
|
parts := strings.SplitN(repoFullName, "/", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
repoInfo.Name = parts[1]
|
||||||
|
}
|
||||||
|
repoInfo.CloneURL = fmt.Sprintf("https://github.com/%s.git", repoFullName)
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath := buildGitHubBackupPath(userID, repoFullName)
|
||||||
|
|
||||||
|
repoCtx, cancel := context.WithTimeout(c.Request.Context(), getGitHubBackupTimeout())
|
||||||
|
sizeBytes, backupErr := backupGitHubRepositoryMirror(repoCtx, accessToken, repoFullName, localPath)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
result := gitHubBackupResult{
|
||||||
|
Repository: repoFullName,
|
||||||
|
LocalPath: localPath,
|
||||||
|
Source: source,
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
record := models.GitHubRepoBackup{
|
||||||
|
UserID: userID,
|
||||||
|
RepositoryID: int64(repoInfo.ID),
|
||||||
|
RepositoryName: repoInfo.Name,
|
||||||
|
RepositoryFullName: repoFullName,
|
||||||
|
DefaultBranch: repoInfo.DefaultBranch,
|
||||||
|
CloneURL: repoInfo.CloneURL,
|
||||||
|
LocalPath: localPath,
|
||||||
|
Source: source,
|
||||||
|
InstallationID: installationID,
|
||||||
|
LastBackupAt: &now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if backupErr != nil {
|
||||||
|
failedCount++
|
||||||
|
result.Status = "error"
|
||||||
|
result.Error = backupErr.Error()
|
||||||
|
record.LastBackupStatus = "error"
|
||||||
|
record.LastBackupError = backupErr.Error()
|
||||||
|
record.LastBackupSize = 0
|
||||||
|
} else {
|
||||||
|
successCount++
|
||||||
|
result.Status = "success"
|
||||||
|
result.SizeBytes = sizeBytes
|
||||||
|
record.LastBackupStatus = "success"
|
||||||
|
record.LastBackupError = ""
|
||||||
|
record.LastBackupSize = sizeBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
if upsertErr := upsertGitHubBackupRecord(db, record); upsertErr != nil {
|
||||||
|
if result.Status == "success" {
|
||||||
|
result.Status = "error"
|
||||||
|
result.Error = "backup persisted but metadata update failed: " + upsertErr.Error()
|
||||||
|
successCount--
|
||||||
|
failedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"source": source,
|
||||||
|
"installation_id": installationID,
|
||||||
|
"backed_up": successCount,
|
||||||
|
"failed": failedCount,
|
||||||
|
"results": results,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGitHubRequestUserID(c *gin.Context) uint {
|
||||||
|
userID := c.GetUint("user_id")
|
||||||
|
if userID == 0 {
|
||||||
|
userID = c.GetUint("userID")
|
||||||
|
}
|
||||||
|
return userID
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserGitHubInstallation(db *gorm.DB, userID uint) (*models.GitHubAppInstallation, error) {
|
||||||
|
var installation models.GitHubAppInstallation
|
||||||
|
if err := db.Where("user_id = ?", userID).Order("updated_at DESC").First(&installation).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &installation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveGitHubBackupToken(c *gin.Context, db *gorm.DB, userID uint, requestedSource string) (string, string, *int64, error) {
|
||||||
|
source := strings.ToLower(strings.TrimSpace(requestedSource))
|
||||||
|
switch source {
|
||||||
|
case "", "oauth":
|
||||||
|
accessToken, err := getGitHubOAuthAccessTokenFromHeader(c)
|
||||||
|
if err == nil {
|
||||||
|
return accessToken, "oauth", nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, installationID, appErr := getGitHubAppAccessTokenForUser(c.Request.Context(), db, userID)
|
||||||
|
if appErr == nil {
|
||||||
|
return accessToken, "github_app", &installationID, nil
|
||||||
|
}
|
||||||
|
return "", "", nil, fmt.Errorf("no usable GitHub OAuth token and GitHub App fallback failed")
|
||||||
|
case "github_app", "app":
|
||||||
|
accessToken, installationID, err := getGitHubAppAccessTokenForUser(c.Request.Context(), db, userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
return accessToken, "github_app", &installationID, nil
|
||||||
|
default:
|
||||||
|
return "", "", nil, fmt.Errorf("unsupported source '%s'", requestedSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGitHubOAuthAccessTokenFromHeader(c *gin.Context) (string, error) {
|
||||||
|
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||||
|
if authHeader == "" {
|
||||||
|
return "", errors.New("authorization header required")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := authHeader
|
||||||
|
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||||
|
tokenString = strings.TrimSpace(authHeader[7:])
|
||||||
|
}
|
||||||
|
if tokenString == "" {
|
||||||
|
return "", errors.New("invalid authorization header")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := ValidateJWT(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := strings.TrimSpace(claims.AccessToken)
|
||||||
|
if accessToken == "" {
|
||||||
|
return "", errors.New("github oauth token missing in jwt")
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGitHubAppAccessTokenForUser(ctx context.Context, db *gorm.DB, userID uint) (string, int64, error) {
|
||||||
|
installation, err := getUserGitHubInstallation(db, userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return "", 0, errors.New("GitHub App not installed for this user")
|
||||||
|
}
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, _, err := createGitHubInstallationAccessToken(ctx, installation.InstallationID)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken, installation.InstallationID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertGitHubBackupRecord(db *gorm.DB, record models.GitHubRepoBackup) error {
|
||||||
|
var existing models.GitHubRepoBackup
|
||||||
|
err := db.Where("user_id = ? AND repository_full_name = ?", record.UserID, record.RepositoryFullName).First(&existing).Error
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||||
|
return db.Create(&record).Error
|
||||||
|
case err != nil:
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"repository_id": record.RepositoryID,
|
||||||
|
"repository_name": record.RepositoryName,
|
||||||
|
"default_branch": record.DefaultBranch,
|
||||||
|
"clone_url": record.CloneURL,
|
||||||
|
"local_path": record.LocalPath,
|
||||||
|
"source": record.Source,
|
||||||
|
"installation_id": record.InstallationID,
|
||||||
|
"last_backup_at": record.LastBackupAt,
|
||||||
|
"last_backup_status": record.LastBackupStatus,
|
||||||
|
"last_backup_error": record.LastBackupError,
|
||||||
|
"last_backup_size": record.LastBackupSize,
|
||||||
|
}
|
||||||
|
return db.Model(&existing).Updates(updates).Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGitHubRepoFullName(raw string) (string, error) {
|
||||||
|
normalized := strings.TrimSpace(raw)
|
||||||
|
normalized = strings.TrimSuffix(normalized, ".git")
|
||||||
|
normalized = strings.TrimPrefix(normalized, "https://github.com/")
|
||||||
|
normalized = strings.TrimPrefix(normalized, "http://github.com/")
|
||||||
|
normalized = strings.TrimPrefix(normalized, "github.com/")
|
||||||
|
normalized = strings.Trim(normalized, "/")
|
||||||
|
if !githubRepoFullNamePattern.MatchString(normalized) {
|
||||||
|
return "", fmt.Errorf("invalid repository '%s', expected owner/repo", raw)
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildGitHubBackupPath(userID uint, repoFullName string) string {
|
||||||
|
parts := strings.SplitN(repoFullName, "/", 2)
|
||||||
|
owner := "unknown"
|
||||||
|
repo := repoFullName
|
||||||
|
if len(parts) == 2 {
|
||||||
|
owner = parts[0]
|
||||||
|
repo = parts[1]
|
||||||
|
}
|
||||||
|
return filepath.Join(getGitHubBackupRoot(), fmt.Sprintf("user-%d", userID), owner, repo+".git")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGitHubBackupRoot() string {
|
||||||
|
root := strings.TrimSpace(os.Getenv("GITHUB_BACKUP_ROOT"))
|
||||||
|
if root == "" {
|
||||||
|
root = filepath.Join("data", "github-backups")
|
||||||
|
}
|
||||||
|
absolutePath, err := filepath.Abs(root)
|
||||||
|
if err != nil {
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
return absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGitHubBackupTimeout() time.Duration {
|
||||||
|
timeoutRaw := strings.TrimSpace(os.Getenv("GITHUB_BACKUP_TIMEOUT"))
|
||||||
|
if timeoutRaw == "" {
|
||||||
|
return 10 * time.Minute
|
||||||
|
}
|
||||||
|
parsed, err := time.ParseDuration(timeoutRaw)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return 10 * time.Minute
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func backupGitHubRepositoryMirror(ctx context.Context, accessToken, repoFullName, localPath string) (int64, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to create backup parent directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repoURL := fmt.Sprintf("https://github.com/%s.git", repoFullName)
|
||||||
|
gitAuthHeader := "http.extraHeader=Authorization: Bearer " + accessToken
|
||||||
|
cloneRequired := true
|
||||||
|
|
||||||
|
if info, err := os.Stat(localPath); err == nil {
|
||||||
|
if !info.IsDir() {
|
||||||
|
return 0, fmt.Errorf("backup path exists and is not a directory: %s", localPath)
|
||||||
|
}
|
||||||
|
if _, configErr := os.Stat(filepath.Join(localPath, "config")); configErr == nil {
|
||||||
|
cloneRequired = false
|
||||||
|
} else if errors.Is(configErr, os.ErrNotExist) {
|
||||||
|
if removeErr := os.RemoveAll(localPath); removeErr != nil {
|
||||||
|
return 0, fmt.Errorf("failed to reset invalid backup directory: %w", removeErr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return 0, fmt.Errorf("failed to inspect existing backup directory: %w", configErr)
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return 0, fmt.Errorf("failed to access backup path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if cloneRequired {
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "-c", gitAuthHeader, "clone", "--mirror", repoURL, localPath)
|
||||||
|
} else {
|
||||||
|
cmd = exec.CommandContext(ctx, "git", "-C", localPath, "-c", gitAuthHeader, "remote", "update", "--prune")
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
commandOutput := strings.TrimSpace(string(output))
|
||||||
|
if commandOutput == "" {
|
||||||
|
commandOutput = err.Error()
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("git backup failed: %s", commandOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
sizeBytes, err := calculateDirectorySize(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("backup completed but failed to calculate size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sizeBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateDirectorySize(root string) (int64, error) {
|
||||||
|
var totalSize int64
|
||||||
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
totalSize += info.Size()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return totalSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchGitHubInstallationRepos(accessToken string) ([]GitHubRepo, error) {
|
||||||
|
req, err := http.NewRequest("GET", "https://api.github.com/installation/repositories?per_page=100", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||||
|
req.Header.Set("User-Agent", "Trackeep")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return nil, fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
|
||||||
|
}
|
||||||
|
|
||||||
|
var response gitHubInstallationReposResponse
|
||||||
|
if err := json.Unmarshal(body, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return response.Repositories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchGitHubRepoByFullName(accessToken, repoFullName string) (*GitHubRepo, error) {
|
||||||
|
parts := strings.SplitN(repoFullName, "/", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return nil, errors.New("invalid repository full name")
|
||||||
|
}
|
||||||
|
repoURL := fmt.Sprintf(
|
||||||
|
"https://api.github.com/repos/%s/%s",
|
||||||
|
url.PathEscape(parts[0]),
|
||||||
|
url.PathEscape(parts[1]),
|
||||||
|
)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", repoURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||||
|
req.Header.Set("User-Agent", "Trackeep")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return nil, fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
|
||||||
|
}
|
||||||
|
|
||||||
|
var repo GitHubRepo
|
||||||
|
if err := json.Unmarshal(body, &repo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &repo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGitHubAppInstallEnabled() bool {
|
||||||
|
return getGitHubAppSlug() != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasGitHubAppCredentials() bool {
|
||||||
|
return strings.TrimSpace(os.Getenv("GITHUB_APP_ID")) != "" &&
|
||||||
|
strings.TrimSpace(os.Getenv("GITHUB_APP_PRIVATE_KEY")) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGitHubAppSlug() string {
|
||||||
|
return strings.TrimSpace(os.Getenv("GITHUB_APP_SLUG"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func createGitHubInstallationAccessToken(ctx context.Context, installationID int64) (string, time.Time, error) {
|
||||||
|
if !hasGitHubAppCredentials() {
|
||||||
|
return "", time.Time{}, errors.New("GitHub App credentials are not fully configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
appJWT, err := createGitHubAppJWT()
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installationID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+appJWT)
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||||
|
req.Header.Set("User-Agent", "Trackeep")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return "", time.Time{}, fmt.Errorf("GitHub token endpoint returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload gitHubInstallationTokenResponse
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(payload.Token) == "" {
|
||||||
|
return "", time.Time{}, errors.New("GitHub returned an empty installation token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt time.Time
|
||||||
|
if payload.ExpiresAt != "" {
|
||||||
|
parsed, parseErr := time.Parse(time.RFC3339, payload.ExpiresAt)
|
||||||
|
if parseErr == nil {
|
||||||
|
expiresAt = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.Token, expiresAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchGitHubAppInstallationDetails(ctx context.Context, installationID int64) (*gitHubAppInstallationDetails, error) {
|
||||||
|
if !hasGitHubAppCredentials() {
|
||||||
|
return nil, errors.New("GitHub App credentials are not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
appJWT, err := createGitHubAppJWT()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("https://api.github.com/app/installations/%d", installationID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+appJWT)
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
|
||||||
|
req.Header.Set("User-Agent", "Trackeep")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return nil, fmt.Errorf("GitHub installation endpoint returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
|
||||||
|
}
|
||||||
|
|
||||||
|
var details gitHubAppInstallationDetails
|
||||||
|
if err := json.Unmarshal(body, &details); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &details, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createGitHubAppJWT() (string, error) {
|
||||||
|
appID := strings.TrimSpace(os.Getenv("GITHUB_APP_ID"))
|
||||||
|
if appID == "" {
|
||||||
|
return "", errors.New("GITHUB_APP_ID is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKeyPEM, err := loadGitHubAppPrivateKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse GitHub App private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
claims := jwt.RegisteredClaims{
|
||||||
|
Issuer: appID,
|
||||||
|
IssuedAt: jwt.NewNumericDate(now.Add(-1 * time.Minute)),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(9 * time.Minute)),
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
signedToken, err := token.SignedString(privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to sign GitHub App JWT: %w", err)
|
||||||
|
}
|
||||||
|
return signedToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadGitHubAppPrivateKey() ([]byte, error) {
|
||||||
|
raw := strings.TrimSpace(os.Getenv("GITHUB_APP_PRIVATE_KEY"))
|
||||||
|
if raw == "" {
|
||||||
|
return nil, errors.New("GITHUB_APP_PRIVATE_KEY is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := strings.ReplaceAll(raw, "\\n", "\n")
|
||||||
|
if strings.Contains(normalized, "BEGIN ") {
|
||||||
|
return []byte(normalized), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(normalized)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("GITHUB_APP_PRIVATE_KEY is neither PEM nor base64-encoded PEM")
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectToGitHubIntegrationPage(c *gin.Context, success bool, installationID int64, setupAction, errorCode string) {
|
||||||
|
frontendURL := strings.TrimSpace(os.Getenv("FRONTEND_URL"))
|
||||||
|
if frontendURL == "" {
|
||||||
|
frontendURL = "http://localhost:3000"
|
||||||
|
}
|
||||||
|
frontendURL = strings.TrimRight(frontendURL, "/")
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
if success {
|
||||||
|
params.Set("github_app_installed", "1")
|
||||||
|
params.Set("installation_id", strconv.FormatInt(installationID, 10))
|
||||||
|
if setupAction != "" {
|
||||||
|
params.Set("setup_action", setupAction)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
params.Set("github_app_error", errorCode)
|
||||||
|
if installationID > 0 {
|
||||||
|
params.Set("installation_id", strconv.FormatInt(installationID, 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectURL := fmt.Sprintf("%s/app/github?%s", frontendURL, params.Encode())
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateString(value string, limit int) string {
|
||||||
|
if len(value) <= limit {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if limit < 4 {
|
||||||
|
return value[:limit]
|
||||||
|
}
|
||||||
|
return value[:limit-3] + "..."
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/trackeep/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultOAuthServiceURL = "https://oauth.trackeep.org"
|
||||||
|
|
||||||
|
type centralizedOAuthUser struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
GitHubID int `json:"github_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type centralizedOAuthValidationResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
User centralizedOAuthUser `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOAuthServiceURL() string {
|
||||||
|
value := strings.TrimSpace(os.Getenv("OAUTH_SERVICE_URL"))
|
||||||
|
if value == "" {
|
||||||
|
value = strings.TrimSpace(os.Getenv("VITE_OAUTH_SERVICE_URL"))
|
||||||
|
}
|
||||||
|
if value == "" {
|
||||||
|
value = defaultOAuthServiceURL
|
||||||
|
}
|
||||||
|
return strings.TrimRight(value, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func headerValue(headers http.Header, key string) string {
|
||||||
|
raw := strings.TrimSpace(headers.Get(key))
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, part := range strings.Split(raw, ",") {
|
||||||
|
candidate := strings.TrimSpace(part)
|
||||||
|
if candidate != "" {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func backendPublicBaseURL(r *http.Request) string {
|
||||||
|
if baseURL := strings.TrimSpace(os.Getenv("PUBLIC_API_URL")); baseURL != "" {
|
||||||
|
return strings.TrimRight(baseURL, "/")
|
||||||
|
}
|
||||||
|
if baseURL := strings.TrimSpace(os.Getenv("PUBLIC_BASE_URL")); baseURL != "" {
|
||||||
|
return strings.TrimRight(baseURL, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme := "http"
|
||||||
|
if forwardedProto := headerValue(r.Header, "X-Forwarded-Proto"); forwardedProto != "" {
|
||||||
|
scheme = forwardedProto
|
||||||
|
} else if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
host := headerValue(r.Header, "X-Forwarded-Host")
|
||||||
|
if host == "" {
|
||||||
|
host = strings.TrimSpace(r.Host)
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s://%s", scheme, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeFrontendRedirectURL(raw string) string {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.Path == "" || parsed.Path == "/" {
|
||||||
|
parsed.Path = "/auth/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveFrontendRedirectURL(r *http.Request) string {
|
||||||
|
if value := normalizeFrontendRedirectURL(r.URL.Query().Get("frontend_redirect")); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if value := normalizeFrontendRedirectURL(os.Getenv("FRONTEND_URL")); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if origin := normalizeFrontendRedirectURL(r.Header.Get("Origin")); origin != "" {
|
||||||
|
return origin
|
||||||
|
}
|
||||||
|
|
||||||
|
referer := strings.TrimSpace(r.Header.Get("Referer"))
|
||||||
|
if referer != "" {
|
||||||
|
if parsed, err := url.Parse(referer); err == nil && parsed.Scheme != "" && parsed.Host != "" {
|
||||||
|
return normalizeFrontendRedirectURL((&url.URL{
|
||||||
|
Scheme: parsed.Scheme,
|
||||||
|
Host: parsed.Host,
|
||||||
|
Path: "/auth/callback",
|
||||||
|
}).String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOAuthCallbackURL(r *http.Request, frontendRedirect string) string {
|
||||||
|
baseURL := backendPublicBaseURL(r)
|
||||||
|
if baseURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackURL, err := url.Parse(baseURL + "/api/v1/auth/oauth/callback")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if frontendRedirect != "" {
|
||||||
|
query := callbackURL.Query()
|
||||||
|
query.Set("frontend_redirect", frontendRedirect)
|
||||||
|
callbackURL.RawQuery = query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
return callbackURL.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFrontendCallbackRedirectURL(frontendRedirect, token string) string {
|
||||||
|
redirectTarget := normalizeFrontendRedirectURL(frontendRedirect)
|
||||||
|
if redirectTarget == "" {
|
||||||
|
redirectTarget = normalizeFrontendRedirectURL(os.Getenv("FRONTEND_URL"))
|
||||||
|
}
|
||||||
|
if redirectTarget == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(redirectTarget)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
query := parsed.Query()
|
||||||
|
query.Set("token", token)
|
||||||
|
parsed.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
return parsed.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCentralizedOAuthToken(ctx context.Context, token string) (*centralizedOAuthValidationResponse, error) {
|
||||||
|
serviceURL := getOAuthServiceURL()
|
||||||
|
if serviceURL == "" {
|
||||||
|
return nil, fmt.Errorf("oauth service url not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
requestBody, err := json.Marshal(map[string]string{"token": token})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serviceURL+"/api/v1/auth/oauth/callback", bytes.NewReader(requestBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
message := strings.TrimSpace(string(body))
|
||||||
|
if message == "" {
|
||||||
|
message = resp.Status
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("oauth service validation failed: %s", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response centralizedOAuthValidationResponse
|
||||||
|
if err := json.Unmarshal(body, &response); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOAuthTokenClaimsUnverified(token string) (jwt.MapClaims, error) {
|
||||||
|
parser := jwt.NewParser()
|
||||||
|
parsedToken, _, err := parser.ParseUnverified(token, jwt.MapClaims{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := parsedToken.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAccessTokenFromOAuthClaims(claims jwt.MapClaims) string {
|
||||||
|
accessToken, _ := claims["access_token"].(string)
|
||||||
|
return strings.TrimSpace(accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueUsername(base string, db *gorm.DB, excludeUserID uint) string {
|
||||||
|
candidate := strings.TrimSpace(base)
|
||||||
|
if candidate == "" {
|
||||||
|
candidate = "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
for suffix := 0; ; suffix++ {
|
||||||
|
username := candidate
|
||||||
|
if suffix > 0 {
|
||||||
|
username = fmt.Sprintf("%s-%d", candidate, suffix+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing models.User
|
||||||
|
err := db.Where("username = ?", username).First(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
if excludeUserID != 0 && existing.ID == excludeUserID {
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertCentralizedOAuthUser(db *gorm.DB, controllerUser centralizedOAuthUser) (*models.User, error) {
|
||||||
|
var user models.User
|
||||||
|
var err error
|
||||||
|
|
||||||
|
normalizedEmail := strings.TrimSpace(controllerUser.Email)
|
||||||
|
normalizedUsername := firstNonEmpty(controllerUser.Username, strings.Split(normalizedEmail, "@")[0], "user")
|
||||||
|
fullName := firstNonEmpty(controllerUser.Name, controllerUser.Username, normalizedEmail)
|
||||||
|
provider := "email"
|
||||||
|
if controllerUser.GitHubID != 0 {
|
||||||
|
provider = "github"
|
||||||
|
err = db.Where("github_id = ?", controllerUser.GitHubID).First(&user).Error
|
||||||
|
} else {
|
||||||
|
err = gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && normalizedEmail != "" {
|
||||||
|
err = db.Where("email = ?", normalizedEmail).First(&user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"email": normalizedEmail,
|
||||||
|
"username": uniqueUsername(normalizedUsername, db, user.ID),
|
||||||
|
"full_name": fullName,
|
||||||
|
"avatar_url": controllerUser.AvatarURL,
|
||||||
|
"provider": provider,
|
||||||
|
}
|
||||||
|
if controllerUser.GitHubID != 0 {
|
||||||
|
updates["github_id"] = controllerUser.GitHubID
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
updates["last_login_at"] = &now
|
||||||
|
|
||||||
|
if err := db.Model(&user).Updates(updates).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := db.First(&user, user.ID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != gorm.ErrRecordNotFound {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var userCount int64
|
||||||
|
if err := db.Model(&models.User{}).Count(&userCount).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
randomPassword := generateRandomString(32)
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(randomPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
role := "user"
|
||||||
|
if userCount == 0 {
|
||||||
|
role = "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
user = models.User{
|
||||||
|
Email: normalizedEmail,
|
||||||
|
Username: uniqueUsername(normalizedUsername, db, 0),
|
||||||
|
Password: string(hashedPassword),
|
||||||
|
FullName: fullName,
|
||||||
|
Role: role,
|
||||||
|
Theme: "dark",
|
||||||
|
GitHubID: controllerUser.GitHubID,
|
||||||
|
AvatarURL: controllerUser.AvatarURL,
|
||||||
|
Provider: provider,
|
||||||
|
LastLoginAt: &now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&user).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = ensureMessagingDefaults(db, user.ID)
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateCentralizedOAuthToken(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Fatalf("expected POST request, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/api/v1/auth/oauth/callback" {
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("failed to decode request body: %v", err)
|
||||||
|
}
|
||||||
|
if body["token"] != "controller-token" {
|
||||||
|
t.Fatalf("unexpected token payload: %#v", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(centralizedOAuthValidationResponse{
|
||||||
|
Token: "controller-token",
|
||||||
|
User: centralizedOAuthUser{
|
||||||
|
ID: 42,
|
||||||
|
GitHubID: 99,
|
||||||
|
Username: "octocat",
|
||||||
|
Email: "octocat@example.com",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
t.Setenv("OAUTH_SERVICE_URL", server.URL)
|
||||||
|
t.Setenv("VITE_OAUTH_SERVICE_URL", "")
|
||||||
|
|
||||||
|
response, err := validateCentralizedOAuthToken(context.Background(), "controller-token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validateCentralizedOAuthToken returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.User.Username != "octocat" {
|
||||||
|
t.Fatalf("unexpected user returned: %#v", response.User)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOAuthCallbackURLPreservesFrontendRedirect(t *testing.T) {
|
||||||
|
frontendRedirect := "https://app.example.com/auth/callback?next=%2Fapp"
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://internal/api/v1/auth/github?frontend_redirect="+url.QueryEscape(frontendRedirect), nil)
|
||||||
|
req.Host = "api.example.com"
|
||||||
|
req.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
req.Header.Set("X-Forwarded-Host", "api.example.com")
|
||||||
|
|
||||||
|
resolvedFrontendRedirect := resolveFrontendRedirectURL(req)
|
||||||
|
if resolvedFrontendRedirect != frontendRedirect {
|
||||||
|
t.Fatalf("unexpected frontend redirect: %s", resolvedFrontendRedirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackURL := buildOAuthCallbackURL(req, resolvedFrontendRedirect)
|
||||||
|
expected := "https://api.example.com/api/v1/auth/oauth/callback?frontend_redirect=" + url.QueryEscape(frontendRedirect)
|
||||||
|
if callbackURL != expected {
|
||||||
|
t.Fatalf("unexpected callback URL: got %s want %s", callbackURL, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOAuthTokenClaimsUnverified(t *testing.T) {
|
||||||
|
signedToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"user_id": 1,
|
||||||
|
"access_token": "gho_test_token",
|
||||||
|
}).SignedString([]byte("test-secret"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to sign token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := parseOAuthTokenClaimsUnverified(signedToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseOAuthTokenClaimsUnverified returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if accessToken := getAccessTokenFromOAuthClaims(claims); accessToken != "gho_test_token" {
|
||||||
|
t.Fatalf("unexpected access token: %s", accessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,7 +91,7 @@ func GetUpdateSettingsForAPI(userID int) (UpdateSettings, error) {
|
|||||||
|
|
||||||
func getDefaultUpdateSettings() UpdateSettings {
|
func getDefaultUpdateSettings() UpdateSettings {
|
||||||
return UpdateSettings{
|
return UpdateSettings{
|
||||||
OAuthServiceURL: getEnvWithDefault("OAUTH_SERVICE_URL", "https://oauth.tdvorak.dev"),
|
OAuthServiceURL: getEnvWithDefault("OAUTH_SERVICE_URL", "https://oauth.trackeep.org"),
|
||||||
AutoUpdateCheck: getBoolEnvWithDefault("AUTO_UPDATE_CHECK", false),
|
AutoUpdateCheck: getBoolEnvWithDefault("AUTO_UPDATE_CHECK", false),
|
||||||
UpdateCheckInterval: getEnvWithDefault("UPDATE_CHECK_INTERVAL", "24h"),
|
UpdateCheckInterval: getEnvWithDefault("UPDATE_CHECK_INTERVAL", "24h"),
|
||||||
PrereleaseUpdates: getBoolEnvWithDefault("PRERELEASE_UPDATES", false),
|
PrereleaseUpdates: getBoolEnvWithDefault("PRERELEASE_UPDATES", false),
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/trackeep/backend/internal/db/sqlc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DB wraps the sqlc DB with additional functionality
|
||||||
|
type DB struct {
|
||||||
|
*sqlc.Queries
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDB creates a new database connection
|
||||||
|
func NewDB() (*DB, error) {
|
||||||
|
// Get database connection string
|
||||||
|
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s",
|
||||||
|
os.Getenv("DB_HOST"),
|
||||||
|
os.Getenv("DB_USER"),
|
||||||
|
os.Getenv("DB_PASSWORD"),
|
||||||
|
os.Getenv("DB_NAME"),
|
||||||
|
os.Getenv("DB_PORT"),
|
||||||
|
os.Getenv("DB_SSL_MODE"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create connection pool
|
||||||
|
pool, err := pgxpool.New(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create connection pool: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
if err := pool.Ping(context.Background()); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create queries instance
|
||||||
|
queries := sqlc.New(pool)
|
||||||
|
|
||||||
|
return &DB{
|
||||||
|
Queries: queries,
|
||||||
|
pool: pool,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection
|
||||||
|
func (db *DB) Close() error {
|
||||||
|
db.pool.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginTx starts a transaction
|
||||||
|
func (db *DB) BeginTx(ctx context.Context) (*DB, error) {
|
||||||
|
tx, err := db.pool.BeginTx(ctx, pgx.TxOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DB{
|
||||||
|
Queries: sqlc.New(tx),
|
||||||
|
pool: nil, // Not using pool in transaction mode
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit commits the transaction
|
||||||
|
func (db *DB) Commit() error {
|
||||||
|
// This would need to be implemented with transaction tracking
|
||||||
|
// For now, transactions should be handled by the caller
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rollback rolls back the transaction
|
||||||
|
func (db *DB) Rollback() error {
|
||||||
|
// This would need to be implemented with transaction tracking
|
||||||
|
// For now, transactions should be handled by the caller
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPool returns the underlying connection pool
|
||||||
|
func (db *DB) GetPool() *pgxpool.Pool {
|
||||||
|
return db.pool
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.30.0
|
||||||
|
// source: bookmarks.sql
|
||||||
|
|
||||||
|
package sqlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const AddBookmarkTag = `-- name: AddBookmarkTag :exec
|
||||||
|
INSERT INTO bookmark_tags (bookmark_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
type AddBookmarkTagParams struct {
|
||||||
|
BookmarkID pgtype.UUID `json:"bookmarkId"`
|
||||||
|
TagID pgtype.UUID `json:"tagId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) AddBookmarkTag(ctx context.Context, arg AddBookmarkTagParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, AddBookmarkTag, arg.BookmarkID, arg.TagID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateBookmark = `-- name: CreateBookmark :one
|
||||||
|
INSERT INTO bookmarks (title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateBookmarkParams struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
FaviconUrl *string `json:"faviconUrl"`
|
||||||
|
ScreenshotUrl *string `json:"screenshotUrl"`
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
IsArchived *bool `json:"isArchived"`
|
||||||
|
IsFavorite *bool `json:"isFavorite"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateBookmark(ctx context.Context, arg CreateBookmarkParams) (Bookmark, error) {
|
||||||
|
row := q.db.QueryRow(ctx, CreateBookmark,
|
||||||
|
arg.Title,
|
||||||
|
arg.Url,
|
||||||
|
arg.Description,
|
||||||
|
arg.FaviconUrl,
|
||||||
|
arg.ScreenshotUrl,
|
||||||
|
arg.UserID,
|
||||||
|
arg.IsArchived,
|
||||||
|
arg.IsFavorite,
|
||||||
|
)
|
||||||
|
var i Bookmark
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Url,
|
||||||
|
&i.Description,
|
||||||
|
&i.FaviconUrl,
|
||||||
|
&i.ScreenshotUrl,
|
||||||
|
&i.UserID,
|
||||||
|
&i.IsArchived,
|
||||||
|
&i.IsFavorite,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteBookmark = `-- name: DeleteBookmark :exec
|
||||||
|
DELETE FROM bookmarks WHERE id = $1 AND user_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteBookmarkParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteBookmark(ctx context.Context, arg DeleteBookmarkParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, DeleteBookmark, arg.ID, arg.UserID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetBookmarkByID = `-- name: GetBookmarkByID :one
|
||||||
|
SELECT id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
|
||||||
|
FROM bookmarks
|
||||||
|
WHERE id = $1 AND user_id = $2 LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetBookmarkByIDParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetBookmarkByID(ctx context.Context, arg GetBookmarkByIDParams) (Bookmark, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetBookmarkByID, arg.ID, arg.UserID)
|
||||||
|
var i Bookmark
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Url,
|
||||||
|
&i.Description,
|
||||||
|
&i.FaviconUrl,
|
||||||
|
&i.ScreenshotUrl,
|
||||||
|
&i.UserID,
|
||||||
|
&i.IsArchived,
|
||||||
|
&i.IsFavorite,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetBookmarksByTag = `-- name: GetBookmarksByTag :many
|
||||||
|
SELECT b.id, b.title, b.url, b.description, b.favicon_url, b.screenshot_url, b.user_id, b.is_archived, b.is_favorite, b.created_at, b.updated_at
|
||||||
|
FROM bookmarks b
|
||||||
|
INNER JOIN bookmark_tags bt ON b.id = bt.bookmark_id
|
||||||
|
INNER JOIN tags t ON bt.tag_id = t.id
|
||||||
|
WHERE t.id = $1 AND b.user_id = $2
|
||||||
|
ORDER BY b.created_at DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetBookmarksByTagParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
Offset int32 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetBookmarksByTag(ctx context.Context, arg GetBookmarksByTagParams) ([]Bookmark, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetBookmarksByTag,
|
||||||
|
arg.ID,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Limit,
|
||||||
|
arg.Offset,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Bookmark{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i Bookmark
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Url,
|
||||||
|
&i.Description,
|
||||||
|
&i.FaviconUrl,
|
||||||
|
&i.ScreenshotUrl,
|
||||||
|
&i.UserID,
|
||||||
|
&i.IsArchived,
|
||||||
|
&i.IsFavorite,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetBookmarksByUser = `-- name: GetBookmarksByUser :many
|
||||||
|
SELECT id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
|
||||||
|
FROM bookmarks
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetBookmarksByUserParams struct {
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
Offset int32 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetBookmarksByUser(ctx context.Context, arg GetBookmarksByUserParams) ([]Bookmark, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetBookmarksByUser, arg.UserID, arg.Limit, arg.Offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Bookmark{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i Bookmark
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Url,
|
||||||
|
&i.Description,
|
||||||
|
&i.FaviconUrl,
|
||||||
|
&i.ScreenshotUrl,
|
||||||
|
&i.UserID,
|
||||||
|
&i.IsArchived,
|
||||||
|
&i.IsFavorite,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const RemoveBookmarkTag = `-- name: RemoveBookmarkTag :exec
|
||||||
|
DELETE FROM bookmark_tags WHERE bookmark_id = $1 AND tag_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type RemoveBookmarkTagParams struct {
|
||||||
|
BookmarkID pgtype.UUID `json:"bookmarkId"`
|
||||||
|
TagID pgtype.UUID `json:"tagId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) RemoveBookmarkTag(ctx context.Context, arg RemoveBookmarkTagParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, RemoveBookmarkTag, arg.BookmarkID, arg.TagID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchBookmarks = `-- name: SearchBookmarks :many
|
||||||
|
SELECT id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
|
||||||
|
FROM bookmarks
|
||||||
|
WHERE user_id = $1 AND (
|
||||||
|
title ILIKE $2 OR
|
||||||
|
description ILIKE $2 OR
|
||||||
|
url ILIKE $2
|
||||||
|
)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
`
|
||||||
|
|
||||||
|
type SearchBookmarksParams struct {
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
Offset int32 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SearchBookmarks(ctx context.Context, arg SearchBookmarksParams) ([]Bookmark, error) {
|
||||||
|
rows, err := q.db.Query(ctx, SearchBookmarks,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Title,
|
||||||
|
arg.Limit,
|
||||||
|
arg.Offset,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Bookmark{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i Bookmark
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Url,
|
||||||
|
&i.Description,
|
||||||
|
&i.FaviconUrl,
|
||||||
|
&i.ScreenshotUrl,
|
||||||
|
&i.UserID,
|
||||||
|
&i.IsArchived,
|
||||||
|
&i.IsFavorite,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateBookmark = `-- name: UpdateBookmark :one
|
||||||
|
UPDATE bookmarks
|
||||||
|
SET title = $2,
|
||||||
|
url = $3,
|
||||||
|
description = $4,
|
||||||
|
favicon_url = $5,
|
||||||
|
screenshot_url = $6,
|
||||||
|
is_archived = $7,
|
||||||
|
is_favorite = $8,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1 AND user_id = $9
|
||||||
|
RETURNING id, title, url, description, favicon_url, screenshot_url, user_id, is_archived, is_favorite, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateBookmarkParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
FaviconUrl *string `json:"faviconUrl"`
|
||||||
|
ScreenshotUrl *string `json:"screenshotUrl"`
|
||||||
|
IsArchived *bool `json:"isArchived"`
|
||||||
|
IsFavorite *bool `json:"isFavorite"`
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateBookmark(ctx context.Context, arg UpdateBookmarkParams) (Bookmark, error) {
|
||||||
|
row := q.db.QueryRow(ctx, UpdateBookmark,
|
||||||
|
arg.ID,
|
||||||
|
arg.Title,
|
||||||
|
arg.Url,
|
||||||
|
arg.Description,
|
||||||
|
arg.FaviconUrl,
|
||||||
|
arg.ScreenshotUrl,
|
||||||
|
arg.IsArchived,
|
||||||
|
arg.IsFavorite,
|
||||||
|
arg.UserID,
|
||||||
|
)
|
||||||
|
var i Bookmark
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Url,
|
||||||
|
&i.Description,
|
||||||
|
&i.FaviconUrl,
|
||||||
|
&i.ScreenshotUrl,
|
||||||
|
&i.UserID,
|
||||||
|
&i.IsArchived,
|
||||||
|
&i.IsFavorite,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.30.0
|
||||||
|
|
||||||
|
package sqlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DBTX interface {
|
||||||
|
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||||
|
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||||
|
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db DBTX) *Queries {
|
||||||
|
return &Queries{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Queries struct {
|
||||||
|
db DBTX
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
|
||||||
|
return &Queries{
|
||||||
|
db: tx,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.30.0
|
||||||
|
|
||||||
|
package sqlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditLog struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
ResourceType string `json:"resourceType"`
|
||||||
|
ResourceID pgtype.UUID `json:"resourceId"`
|
||||||
|
OldValues []byte `json:"oldValues"`
|
||||||
|
NewValues []byte `json:"newValues"`
|
||||||
|
IpAddress *netip.Addr `json:"ipAddress"`
|
||||||
|
UserAgent *string `json:"userAgent"`
|
||||||
|
CreatedAt pgtype.Timestamp `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bookmark struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
FaviconUrl *string `json:"faviconUrl"`
|
||||||
|
ScreenshotUrl *string `json:"screenshotUrl"`
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
IsArchived *bool `json:"isArchived"`
|
||||||
|
IsFavorite *bool `json:"isFavorite"`
|
||||||
|
CreatedAt pgtype.Timestamp `json:"createdAt"`
|
||||||
|
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BookmarkTag struct {
|
||||||
|
BookmarkID pgtype.UUID `json:"bookmarkId"`
|
||||||
|
TagID pgtype.UUID `json:"tagId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
OriginalName string `json:"originalName"`
|
||||||
|
FileSize int64 `json:"fileSize"`
|
||||||
|
MimeType *string `json:"mimeType"`
|
||||||
|
FilePath string `json:"filePath"`
|
||||||
|
ThumbnailPath *string `json:"thumbnailPath"`
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
CreatedAt pgtype.Timestamp `json:"createdAt"`
|
||||||
|
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileTag struct {
|
||||||
|
FileID pgtype.UUID `json:"fileId"`
|
||||||
|
TagID pgtype.UUID `json:"tagId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Note struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content *string `json:"content"`
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
CreatedAt pgtype.Timestamp `json:"createdAt"`
|
||||||
|
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoteTag struct {
|
||||||
|
NoteID pgtype.UUID `json:"noteId"`
|
||||||
|
TagID pgtype.UUID `json:"tagId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color *string `json:"color"`
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
CreatedAt pgtype.Timestamp `json:"createdAt"`
|
||||||
|
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Task struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
Priority *string `json:"priority"`
|
||||||
|
DueDate pgtype.Timestamp `json:"dueDate"`
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
CreatedAt pgtype.Timestamp `json:"createdAt"`
|
||||||
|
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskTag struct {
|
||||||
|
TaskID pgtype.UUID `json:"taskId"`
|
||||||
|
TagID pgtype.UUID `json:"tagId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
PasswordHash string `json:"passwordHash"`
|
||||||
|
FirstName *string `json:"firstName"`
|
||||||
|
LastName *string `json:"lastName"`
|
||||||
|
AvatarUrl *string `json:"avatarUrl"`
|
||||||
|
IsActive *bool `json:"isActive"`
|
||||||
|
IsVerified *bool `json:"isVerified"`
|
||||||
|
LastLogin pgtype.Timestamp `json:"lastLogin"`
|
||||||
|
CreatedAt pgtype.Timestamp `json:"createdAt"`
|
||||||
|
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.30.0
|
||||||
|
|
||||||
|
package sqlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Querier interface {
|
||||||
|
AddBookmarkTag(ctx context.Context, arg AddBookmarkTagParams) error
|
||||||
|
AddTaskTag(ctx context.Context, arg AddTaskTagParams) error
|
||||||
|
CreateBookmark(ctx context.Context, arg CreateBookmarkParams) (Bookmark, error)
|
||||||
|
CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error)
|
||||||
|
CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
|
||||||
|
DeleteBookmark(ctx context.Context, arg DeleteBookmarkParams) error
|
||||||
|
DeleteTask(ctx context.Context, arg DeleteTaskParams) error
|
||||||
|
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||||
|
GetBookmarkByID(ctx context.Context, arg GetBookmarkByIDParams) (Bookmark, error)
|
||||||
|
GetBookmarksByTag(ctx context.Context, arg GetBookmarksByTagParams) ([]Bookmark, error)
|
||||||
|
GetBookmarksByUser(ctx context.Context, arg GetBookmarksByUserParams) ([]Bookmark, error)
|
||||||
|
GetTaskByID(ctx context.Context, arg GetTaskByIDParams) (Task, error)
|
||||||
|
GetTasksByStatus(ctx context.Context, arg GetTasksByStatusParams) ([]Task, error)
|
||||||
|
GetTasksByTag(ctx context.Context, arg GetTasksByTagParams) ([]Task, error)
|
||||||
|
GetTasksByUser(ctx context.Context, arg GetTasksByUserParams) ([]Task, error)
|
||||||
|
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
|
||||||
|
GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDRow, error)
|
||||||
|
ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUsersRow, error)
|
||||||
|
RemoveBookmarkTag(ctx context.Context, arg RemoveBookmarkTagParams) error
|
||||||
|
RemoveTaskTag(ctx context.Context, arg RemoveTaskTagParams) error
|
||||||
|
SearchBookmarks(ctx context.Context, arg SearchBookmarksParams) ([]Bookmark, error)
|
||||||
|
SearchTasks(ctx context.Context, arg SearchTasksParams) ([]Task, error)
|
||||||
|
UpdateBookmark(ctx context.Context, arg UpdateBookmarkParams) (Bookmark, error)
|
||||||
|
UpdateLastLogin(ctx context.Context, id pgtype.UUID) error
|
||||||
|
UpdateTask(ctx context.Context, arg UpdateTaskParams) (Task, error)
|
||||||
|
UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Querier = (*Queries)(nil)
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.30.0
|
||||||
|
// source: tasks.sql
|
||||||
|
|
||||||
|
package sqlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const AddTaskTag = `-- name: AddTaskTag :exec
|
||||||
|
INSERT INTO task_tags (task_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
type AddTaskTagParams struct {
|
||||||
|
TaskID pgtype.UUID `json:"taskId"`
|
||||||
|
TagID pgtype.UUID `json:"tagId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) AddTaskTag(ctx context.Context, arg AddTaskTagParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, AddTaskTag, arg.TaskID, arg.TagID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateTask = `-- name: CreateTask :one
|
||||||
|
INSERT INTO tasks (title, description, status, priority, due_date, user_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, title, description, status, priority, due_date, user_id, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateTaskParams struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
Priority *string `json:"priority"`
|
||||||
|
DueDate pgtype.Timestamp `json:"dueDate"`
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) {
|
||||||
|
row := q.db.QueryRow(ctx, CreateTask,
|
||||||
|
arg.Title,
|
||||||
|
arg.Description,
|
||||||
|
arg.Status,
|
||||||
|
arg.Priority,
|
||||||
|
arg.DueDate,
|
||||||
|
arg.UserID,
|
||||||
|
)
|
||||||
|
var i Task
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Description,
|
||||||
|
&i.Status,
|
||||||
|
&i.Priority,
|
||||||
|
&i.DueDate,
|
||||||
|
&i.UserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteTask = `-- name: DeleteTask :exec
|
||||||
|
DELETE FROM tasks WHERE id = $1 AND user_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteTaskParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteTask(ctx context.Context, arg DeleteTaskParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, DeleteTask, arg.ID, arg.UserID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetTaskByID = `-- name: GetTaskByID :one
|
||||||
|
SELECT id, title, description, status, priority, due_date, user_id, created_at, updated_at
|
||||||
|
FROM tasks
|
||||||
|
WHERE id = $1 AND user_id = $2 LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetTaskByIDParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetTaskByID(ctx context.Context, arg GetTaskByIDParams) (Task, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetTaskByID, arg.ID, arg.UserID)
|
||||||
|
var i Task
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Description,
|
||||||
|
&i.Status,
|
||||||
|
&i.Priority,
|
||||||
|
&i.DueDate,
|
||||||
|
&i.UserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetTasksByStatus = `-- name: GetTasksByStatus :many
|
||||||
|
SELECT id, title, description, status, priority, due_date, user_id, created_at, updated_at
|
||||||
|
FROM tasks
|
||||||
|
WHERE user_id = $1 AND status = $2
|
||||||
|
ORDER BY
|
||||||
|
CASE priority
|
||||||
|
WHEN 'high' THEN 1
|
||||||
|
WHEN 'medium' THEN 2
|
||||||
|
WHEN 'low' THEN 3
|
||||||
|
END,
|
||||||
|
due_date ASC NULLS LAST,
|
||||||
|
created_at DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetTasksByStatusParams struct {
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
Offset int32 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetTasksByStatus(ctx context.Context, arg GetTasksByStatusParams) ([]Task, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetTasksByStatus,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Status,
|
||||||
|
arg.Limit,
|
||||||
|
arg.Offset,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Task{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i Task
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Description,
|
||||||
|
&i.Status,
|
||||||
|
&i.Priority,
|
||||||
|
&i.DueDate,
|
||||||
|
&i.UserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetTasksByTag = `-- name: GetTasksByTag :many
|
||||||
|
SELECT t.id, t.title, t.description, t.status, t.priority, t.due_date, t.user_id, t.created_at, t.updated_at
|
||||||
|
FROM tasks t
|
||||||
|
INNER JOIN task_tags tt ON t.id = tt.task_id
|
||||||
|
INNER JOIN tags tg ON tt.tag_id = tg.id
|
||||||
|
WHERE tg.id = $1 AND t.user_id = $2
|
||||||
|
ORDER BY
|
||||||
|
CASE t.priority
|
||||||
|
WHEN 'high' THEN 1
|
||||||
|
WHEN 'medium' THEN 2
|
||||||
|
WHEN 'low' THEN 3
|
||||||
|
END,
|
||||||
|
t.due_date ASC NULLS LAST,
|
||||||
|
t.created_at DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetTasksByTagParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
Offset int32 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetTasksByTag(ctx context.Context, arg GetTasksByTagParams) ([]Task, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetTasksByTag,
|
||||||
|
arg.ID,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Limit,
|
||||||
|
arg.Offset,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Task{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i Task
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Description,
|
||||||
|
&i.Status,
|
||||||
|
&i.Priority,
|
||||||
|
&i.DueDate,
|
||||||
|
&i.UserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetTasksByUser = `-- name: GetTasksByUser :many
|
||||||
|
SELECT id, title, description, status, priority, due_date, user_id, created_at, updated_at
|
||||||
|
FROM tasks
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY
|
||||||
|
CASE priority
|
||||||
|
WHEN 'high' THEN 1
|
||||||
|
WHEN 'medium' THEN 2
|
||||||
|
WHEN 'low' THEN 3
|
||||||
|
END,
|
||||||
|
due_date ASC NULLS LAST,
|
||||||
|
created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetTasksByUserParams struct {
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
Offset int32 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetTasksByUser(ctx context.Context, arg GetTasksByUserParams) ([]Task, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetTasksByUser, arg.UserID, arg.Limit, arg.Offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Task{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i Task
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Description,
|
||||||
|
&i.Status,
|
||||||
|
&i.Priority,
|
||||||
|
&i.DueDate,
|
||||||
|
&i.UserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const RemoveTaskTag = `-- name: RemoveTaskTag :exec
|
||||||
|
DELETE FROM task_tags WHERE task_id = $1 AND tag_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type RemoveTaskTagParams struct {
|
||||||
|
TaskID pgtype.UUID `json:"taskId"`
|
||||||
|
TagID pgtype.UUID `json:"tagId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) RemoveTaskTag(ctx context.Context, arg RemoveTaskTagParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, RemoveTaskTag, arg.TaskID, arg.TagID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchTasks = `-- name: SearchTasks :many
|
||||||
|
SELECT id, title, description, status, priority, due_date, user_id, created_at, updated_at
|
||||||
|
FROM tasks
|
||||||
|
WHERE user_id = $1 AND (
|
||||||
|
title ILIKE $2 OR
|
||||||
|
description ILIKE $2
|
||||||
|
)
|
||||||
|
ORDER BY
|
||||||
|
CASE priority
|
||||||
|
WHEN 'high' THEN 1
|
||||||
|
WHEN 'medium' THEN 2
|
||||||
|
WHEN 'low' THEN 3
|
||||||
|
END,
|
||||||
|
due_date ASC NULLS LAST,
|
||||||
|
created_at DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
`
|
||||||
|
|
||||||
|
type SearchTasksParams struct {
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
Offset int32 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SearchTasks(ctx context.Context, arg SearchTasksParams) ([]Task, error) {
|
||||||
|
rows, err := q.db.Query(ctx, SearchTasks,
|
||||||
|
arg.UserID,
|
||||||
|
arg.Title,
|
||||||
|
arg.Limit,
|
||||||
|
arg.Offset,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Task{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i Task
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Description,
|
||||||
|
&i.Status,
|
||||||
|
&i.Priority,
|
||||||
|
&i.DueDate,
|
||||||
|
&i.UserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateTask = `-- name: UpdateTask :one
|
||||||
|
UPDATE tasks
|
||||||
|
SET title = $2,
|
||||||
|
description = $3,
|
||||||
|
status = $4,
|
||||||
|
priority = $5,
|
||||||
|
due_date = $6,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1 AND user_id = $7
|
||||||
|
RETURNING id, title, description, status, priority, due_date, user_id, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateTaskParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
Priority *string `json:"priority"`
|
||||||
|
DueDate pgtype.Timestamp `json:"dueDate"`
|
||||||
|
UserID pgtype.UUID `json:"userId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateTask(ctx context.Context, arg UpdateTaskParams) (Task, error) {
|
||||||
|
row := q.db.QueryRow(ctx, UpdateTask,
|
||||||
|
arg.ID,
|
||||||
|
arg.Title,
|
||||||
|
arg.Description,
|
||||||
|
arg.Status,
|
||||||
|
arg.Priority,
|
||||||
|
arg.DueDate,
|
||||||
|
arg.UserID,
|
||||||
|
)
|
||||||
|
var i Task
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Description,
|
||||||
|
&i.Status,
|
||||||
|
&i.Priority,
|
||||||
|
&i.DueDate,
|
||||||
|
&i.UserID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.30.0
|
||||||
|
// source: users.sql
|
||||||
|
|
||||||
|
package sqlc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const CreateUser = `-- name: CreateUser :one
|
||||||
|
INSERT INTO users (email, password_hash, first_name, last_name, avatar_url, is_active, is_verified)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type CreateUserParams struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
PasswordHash string `json:"passwordHash"`
|
||||||
|
FirstName *string `json:"firstName"`
|
||||||
|
LastName *string `json:"lastName"`
|
||||||
|
AvatarUrl *string `json:"avatarUrl"`
|
||||||
|
IsActive *bool `json:"isActive"`
|
||||||
|
IsVerified *bool `json:"isVerified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateUserRow struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
FirstName *string `json:"firstName"`
|
||||||
|
LastName *string `json:"lastName"`
|
||||||
|
AvatarUrl *string `json:"avatarUrl"`
|
||||||
|
IsActive *bool `json:"isActive"`
|
||||||
|
IsVerified *bool `json:"isVerified"`
|
||||||
|
LastLogin pgtype.Timestamp `json:"lastLogin"`
|
||||||
|
CreatedAt pgtype.Timestamp `json:"createdAt"`
|
||||||
|
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, CreateUser,
|
||||||
|
arg.Email,
|
||||||
|
arg.PasswordHash,
|
||||||
|
arg.FirstName,
|
||||||
|
arg.LastName,
|
||||||
|
arg.AvatarUrl,
|
||||||
|
arg.IsActive,
|
||||||
|
arg.IsVerified,
|
||||||
|
)
|
||||||
|
var i CreateUserRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.FirstName,
|
||||||
|
&i.LastName,
|
||||||
|
&i.AvatarUrl,
|
||||||
|
&i.IsActive,
|
||||||
|
&i.IsVerified,
|
||||||
|
&i.LastLogin,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteUser = `-- name: DeleteUser :exec
|
||||||
|
DELETE FROM users WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, DeleteUser, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetUserByEmail = `-- name: GetUserByEmail :one
|
||||||
|
SELECT id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
WHERE email = $1 LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetUserByEmailRow struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
FirstName *string `json:"firstName"`
|
||||||
|
LastName *string `json:"lastName"`
|
||||||
|
AvatarUrl *string `json:"avatarUrl"`
|
||||||
|
IsActive *bool `json:"isActive"`
|
||||||
|
IsVerified *bool `json:"isVerified"`
|
||||||
|
LastLogin pgtype.Timestamp `json:"lastLogin"`
|
||||||
|
CreatedAt pgtype.Timestamp `json:"createdAt"`
|
||||||
|
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetUserByEmail, email)
|
||||||
|
var i GetUserByEmailRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.FirstName,
|
||||||
|
&i.LastName,
|
||||||
|
&i.AvatarUrl,
|
||||||
|
&i.IsActive,
|
||||||
|
&i.IsVerified,
|
||||||
|
&i.LastLogin,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const GetUserByID = `-- name: GetUserByID :one
|
||||||
|
SELECT id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1 LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetUserByIDRow struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
FirstName *string `json:"firstName"`
|
||||||
|
LastName *string `json:"lastName"`
|
||||||
|
AvatarUrl *string `json:"avatarUrl"`
|
||||||
|
IsActive *bool `json:"isActive"`
|
||||||
|
IsVerified *bool `json:"isVerified"`
|
||||||
|
LastLogin pgtype.Timestamp `json:"lastLogin"`
|
||||||
|
CreatedAt pgtype.Timestamp `json:"createdAt"`
|
||||||
|
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (GetUserByIDRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, GetUserByID, id)
|
||||||
|
var i GetUserByIDRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.FirstName,
|
||||||
|
&i.LastName,
|
||||||
|
&i.AvatarUrl,
|
||||||
|
&i.IsActive,
|
||||||
|
&i.IsVerified,
|
||||||
|
&i.LastLogin,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListUsers = `-- name: ListUsers :many
|
||||||
|
SELECT id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListUsersParams struct {
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
Offset int32 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListUsersRow struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
FirstName *string `json:"firstName"`
|
||||||
|
LastName *string `json:"lastName"`
|
||||||
|
AvatarUrl *string `json:"avatarUrl"`
|
||||||
|
IsActive *bool `json:"isActive"`
|
||||||
|
IsVerified *bool `json:"isVerified"`
|
||||||
|
LastLogin pgtype.Timestamp `json:"lastLogin"`
|
||||||
|
CreatedAt pgtype.Timestamp `json:"createdAt"`
|
||||||
|
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUsersRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, ListUsers, arg.Limit, arg.Offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []ListUsersRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListUsersRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.FirstName,
|
||||||
|
&i.LastName,
|
||||||
|
&i.AvatarUrl,
|
||||||
|
&i.IsActive,
|
||||||
|
&i.IsVerified,
|
||||||
|
&i.LastLogin,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateLastLogin = `-- name: UpdateLastLogin :exec
|
||||||
|
UPDATE users
|
||||||
|
SET last_login = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) UpdateLastLogin(ctx context.Context, id pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, UpdateLastLogin, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateUser = `-- name: UpdateUser :one
|
||||||
|
UPDATE users
|
||||||
|
SET first_name = $2,
|
||||||
|
last_name = $3,
|
||||||
|
avatar_url = $4,
|
||||||
|
is_active = $5,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, email, first_name, last_name, avatar_url, is_active, is_verified, last_login, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateUserParams struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
FirstName *string `json:"firstName"`
|
||||||
|
LastName *string `json:"lastName"`
|
||||||
|
AvatarUrl *string `json:"avatarUrl"`
|
||||||
|
IsActive *bool `json:"isActive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateUserRow struct {
|
||||||
|
ID pgtype.UUID `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
FirstName *string `json:"firstName"`
|
||||||
|
LastName *string `json:"lastName"`
|
||||||
|
AvatarUrl *string `json:"avatarUrl"`
|
||||||
|
IsActive *bool `json:"isActive"`
|
||||||
|
IsVerified *bool `json:"isVerified"`
|
||||||
|
LastLogin pgtype.Timestamp `json:"lastLogin"`
|
||||||
|
CreatedAt pgtype.Timestamp `json:"createdAt"`
|
||||||
|
UpdatedAt pgtype.Timestamp `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (UpdateUserRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, UpdateUser,
|
||||||
|
arg.ID,
|
||||||
|
arg.FirstName,
|
||||||
|
arg.LastName,
|
||||||
|
arg.AvatarUrl,
|
||||||
|
arg.IsActive,
|
||||||
|
)
|
||||||
|
var i UpdateUserRow
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Email,
|
||||||
|
&i.FirstName,
|
||||||
|
&i.LastName,
|
||||||
|
&i.AvatarUrl,
|
||||||
|
&i.IsActive,
|
||||||
|
&i.IsVerified,
|
||||||
|
&i.LastLogin,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -149,7 +149,7 @@ func main() {
|
|||||||
// }()
|
// }()
|
||||||
|
|
||||||
// Set Gin mode
|
// Set Gin mode
|
||||||
if os.Getenv("GIN_MODE") == "release" {
|
if os.Getenv("GIN_MODE") == "release" || os.Getenv("GIN_MODE") == "production" {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +159,7 @@ func main() {
|
|||||||
// Middleware
|
// Middleware
|
||||||
r.Use(gin.Logger())
|
r.Use(gin.Logger())
|
||||||
r.Use(gin.Recovery())
|
r.Use(gin.Recovery())
|
||||||
|
r.Use(middleware.CORSMiddleware())
|
||||||
r.Use(middleware.CacheMiddleware(cacheConfig)) // Add DragonflyDB cache middleware
|
r.Use(middleware.CacheMiddleware(cacheConfig)) // Add DragonflyDB cache middleware
|
||||||
r.Use(middleware.CacheInvalidationMiddleware(dragonflyClient)) // Add cache invalidation
|
r.Use(middleware.CacheInvalidationMiddleware(dragonflyClient)) // Add cache invalidation
|
||||||
r.Use(middleware.SessionMiddleware()) // Add session middleware
|
r.Use(middleware.SessionMiddleware()) // Add session middleware
|
||||||
@@ -172,8 +173,6 @@ func main() {
|
|||||||
// Apply general rate limiting to all endpoints
|
// Apply general rate limiting to all endpoints
|
||||||
r.Use(middleware.GeneralRateLimit(rateLimiters["general"]))
|
r.Use(middleware.GeneralRateLimit(rateLimiters["general"]))
|
||||||
|
|
||||||
r.Use(middleware.CORSMiddleware())
|
|
||||||
|
|
||||||
r.GET("/health", handlers.HealthCheck)
|
r.GET("/health", handlers.HealthCheck)
|
||||||
r.GET("/ready", handlers.ReadinessCheck)
|
r.GET("/ready", handlers.ReadinessCheck)
|
||||||
r.GET("/live", handlers.LivenessCheck)
|
r.GET("/live", handlers.LivenessCheck)
|
||||||
@@ -231,7 +230,7 @@ func main() {
|
|||||||
auth.POST("/login", handlers.Login)
|
auth.POST("/login", handlers.Login)
|
||||||
auth.POST("/login-totp", handlers.LoginWithTOTP)
|
auth.POST("/login-totp", handlers.LoginWithTOTP)
|
||||||
auth.POST("/logout", handlers.Logout)
|
auth.POST("/logout", handlers.Logout)
|
||||||
auth.GET("/me", handlers.GetCurrentUserWithGitHub)
|
auth.GET("/me", handlers.AuthMiddleware(), handlers.GetCurrentUserWithGitHub)
|
||||||
auth.POST("/password-reset", handlers.RequestPasswordReset)
|
auth.POST("/password-reset", handlers.RequestPasswordReset)
|
||||||
auth.POST("/password-reset/confirm", handlers.ConfirmPasswordReset)
|
auth.POST("/password-reset/confirm", handlers.ConfirmPasswordReset)
|
||||||
|
|
||||||
@@ -241,11 +240,19 @@ func main() {
|
|||||||
auth.GET("/oauth/callback", handlers.HandleOAuthCallback)
|
auth.GET("/oauth/callback", handlers.HandleOAuthCallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GitHub App callback (public for GitHub redirect)
|
||||||
|
v1.GET("/github/app/callback", handlers.GitHubAppInstallCallback)
|
||||||
|
|
||||||
// GitHub routes (protected)
|
// GitHub routes (protected)
|
||||||
github := v1.Group("/github")
|
github := v1.Group("/github")
|
||||||
github.Use(handlers.AuthMiddleware())
|
github.Use(handlers.AuthMiddleware())
|
||||||
{
|
{
|
||||||
github.GET("/repos", handlers.GetGitHubRepos)
|
github.GET("/repos", handlers.GetGitHubRepos)
|
||||||
|
github.GET("/app/status", handlers.GetGitHubAppStatus)
|
||||||
|
github.GET("/app/install-url", handlers.GetGitHubAppInstallURL)
|
||||||
|
github.GET("/app/repos", handlers.GetGitHubAppRepos)
|
||||||
|
github.GET("/backups", handlers.GetGitHubBackups)
|
||||||
|
github.POST("/backups", handlers.BackupGitHubRepositories)
|
||||||
}
|
}
|
||||||
|
|
||||||
v1.POST("/youtube-search-test", handlers.YouTubeSearchTest)
|
v1.POST("/youtube-search-test", handlers.YouTubeSearchTest)
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ func CacheMiddleware(config CacheConfig) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip caching for auth/bootstrap requests and authenticated traffic.
|
||||||
|
if shouldSkipCache(c) {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Generate cache key
|
// Generate cache key
|
||||||
cacheKey := generateCacheKey(c, config.KeyPrefix)
|
cacheKey := generateCacheKey(c, config.KeyPrefix)
|
||||||
|
|
||||||
@@ -82,6 +88,20 @@ func CacheMiddleware(config CacheConfig) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldSkipCache(c *gin.Context) bool {
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
|
||||||
|
if strings.HasPrefix(path, "/api/v1/auth/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.GetHeader("Authorization") != "" || c.GetHeader("Cookie") != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// generateCacheKey creates a unique cache key for the request
|
// generateCacheKey creates a unique cache key for the request
|
||||||
func generateCacheKey(c *gin.Context, prefix string) string {
|
func generateCacheKey(c *gin.Context, prefix string) string {
|
||||||
// Include path, query params, and user ID if available
|
// Include path, query params, and user ID if available
|
||||||
|
|||||||
@@ -27,27 +27,19 @@ func CORSMiddleware() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
origin := c.Request.Header.Get("Origin")
|
origin := c.Request.Header.Get("Origin")
|
||||||
allowed := false
|
|
||||||
|
|
||||||
|
// Always set CORS headers
|
||||||
if allowedOrigins == "*" {
|
if allowedOrigins == "*" {
|
||||||
allowed = true
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
} else {
|
} else {
|
||||||
for _, allowedOrigin := range strings.Split(allowedOrigins, ",") {
|
for _, allowedOrigin := range strings.Split(allowedOrigins, ",") {
|
||||||
if strings.TrimSpace(allowedOrigin) == origin {
|
if strings.TrimSpace(allowedOrigin) == origin {
|
||||||
allowed = true
|
c.Header("Access-Control-Allow-Origin", origin)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if allowed {
|
|
||||||
if allowedOrigins == "*" {
|
|
||||||
c.Header("Access-Control-Allow-Origin", "*")
|
|
||||||
} else {
|
|
||||||
c.Header("Access-Control-Allow-Origin", origin)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||||
c.Header("Access-Control-Allow-Credentials", "true")
|
c.Header("Access-Control-Allow-Credentials", "true")
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/trackeep/backend/config"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoggerConfig holds configuration for the logger
|
// LoggerConfig holds configuration for the logger
|
||||||
@@ -19,17 +17,14 @@ type LoggerConfig struct {
|
|||||||
EnableJSON bool
|
EnableJSON bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logger returns a middleware that logs HTTP requests
|
// GetLogger returns the logger instance
|
||||||
|
func (lc LoggerConfig) GetLogger() *zap.Logger {
|
||||||
|
return config.GetLogger()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger returns a middleware that logs HTTP requests using Zap
|
||||||
func Logger(config LoggerConfig) gin.HandlerFunc {
|
func Logger(config LoggerConfig) gin.HandlerFunc {
|
||||||
// Create log file if specified
|
logger := config.GetLogger()
|
||||||
var file *os.File
|
|
||||||
if config.LogFile != "" {
|
|
||||||
var err error
|
|
||||||
file, err = os.OpenFile(config.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to open log file: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
||||||
// Create log entry
|
// Create log entry
|
||||||
@@ -45,7 +40,9 @@ func Logger(config LoggerConfig) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add user ID if available
|
// Add user ID if available
|
||||||
if userID, exists := param.Keys["user_id"]; exists {
|
var userID interface{}
|
||||||
|
if uid, exists := param.Keys["user_id"]; exists {
|
||||||
|
userID = uid
|
||||||
entry["user_id"] = userID
|
entry["user_id"] = userID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,40 +51,39 @@ func Logger(config LoggerConfig) gin.HandlerFunc {
|
|||||||
entry["error"] = param.ErrorMessage
|
entry["error"] = param.ErrorMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format output
|
// Log with Zap
|
||||||
var output string
|
if param.ErrorMessage != "" {
|
||||||
if config.EnableJSON {
|
logger.Error("HTTP request",
|
||||||
jsonData, _ := json.Marshal(entry)
|
zap.String("method", param.Method),
|
||||||
output = string(jsonData) + "\n"
|
zap.String("path", param.Path),
|
||||||
} else {
|
zap.Int("status", param.StatusCode),
|
||||||
output = fmt.Sprintf("[%s] %s %s %d %s %s %s",
|
zap.Duration("latency", param.Latency),
|
||||||
entry["timestamp"],
|
zap.String("client_ip", param.ClientIP),
|
||||||
entry["method"],
|
zap.String("user_agent", param.Request.UserAgent()),
|
||||||
entry["path"],
|
zap.Any("user_id", userID),
|
||||||
entry["status"],
|
zap.String("error", param.ErrorMessage),
|
||||||
entry["latency"],
|
)
|
||||||
entry["client_ip"],
|
} else {
|
||||||
entry["user_agent"],
|
logger.Info("HTTP request",
|
||||||
|
zap.String("method", param.Method),
|
||||||
|
zap.String("path", param.Path),
|
||||||
|
zap.Int("status", param.StatusCode),
|
||||||
|
zap.Duration("latency", param.Latency),
|
||||||
|
zap.String("client_ip", param.ClientIP),
|
||||||
|
zap.String("user_agent", param.Request.UserAgent()),
|
||||||
|
zap.Any("user_id", userID),
|
||||||
)
|
)
|
||||||
if userID, exists := entry["user_id"]; exists {
|
|
||||||
output += fmt.Sprintf(" user_id:%v", userID)
|
|
||||||
}
|
|
||||||
if param.ErrorMessage != "" {
|
|
||||||
output += fmt.Sprintf(" error:%s", param.ErrorMessage)
|
|
||||||
}
|
|
||||||
output += "\n"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to file and console
|
// Return empty string since Zap handles output
|
||||||
if file != nil {
|
return ""
|
||||||
file.WriteString(output)
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequestLogger logs detailed request information
|
// RequestLogger logs detailed request information using Zap
|
||||||
func RequestLogger() gin.HandlerFunc {
|
func RequestLogger() gin.HandlerFunc {
|
||||||
|
logger := config.GetLogger()
|
||||||
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
@@ -104,12 +100,6 @@ func RequestLogger() gin.HandlerFunc {
|
|||||||
// Calculate latency
|
// Calculate latency
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
|
|
||||||
// Get client IP
|
|
||||||
clientIP := c.ClientIP()
|
|
||||||
|
|
||||||
// Get status code
|
|
||||||
statusCode := c.Writer.Status()
|
|
||||||
|
|
||||||
// Get request ID
|
// Get request ID
|
||||||
requestID := c.GetHeader("X-Request-ID")
|
requestID := c.GetHeader("X-Request-ID")
|
||||||
if requestID == "" {
|
if requestID == "" {
|
||||||
@@ -122,46 +112,52 @@ func RequestLogger() gin.HandlerFunc {
|
|||||||
userID = uid
|
userID = uid
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create log entry
|
// Log request body for POST/PUT requests (excluding sensitive data)
|
||||||
logEntry := map[string]interface{}{
|
var requestBody string
|
||||||
"timestamp": start.Format(time.RFC3339),
|
if c.Request.Method == "POST" || c.Request.Method == "PUT" {
|
||||||
"request_id": requestID,
|
requestBody = logRequestBody(c)
|
||||||
"method": c.Request.Method,
|
}
|
||||||
"path": path,
|
|
||||||
"query": raw,
|
// Create log fields
|
||||||
"status": statusCode,
|
fields := []zap.Field{
|
||||||
"latency_ms": latency.Milliseconds(),
|
zap.String("request_id", requestID),
|
||||||
"client_ip": clientIP,
|
zap.String("method", c.Request.Method),
|
||||||
"user_agent": c.Request.UserAgent(),
|
zap.String("path", path),
|
||||||
"referer": c.Request.Referer(),
|
zap.String("query", raw),
|
||||||
"content_type": c.GetHeader("Content-Type"),
|
zap.Int("status", c.Writer.Status()),
|
||||||
"content_length": c.Request.ContentLength,
|
zap.Duration("latency_ms", latency),
|
||||||
|
zap.String("client_ip", c.ClientIP()),
|
||||||
|
zap.String("user_agent", c.Request.UserAgent()),
|
||||||
|
zap.String("referer", c.Request.Referer()),
|
||||||
|
zap.String("content_type", c.GetHeader("Content-Type")),
|
||||||
|
zap.Int64("content_length", c.Request.ContentLength),
|
||||||
}
|
}
|
||||||
|
|
||||||
if userID != nil {
|
if userID != nil {
|
||||||
logEntry["user_id"] = userID
|
fields = append(fields, zap.Any("user_id", userID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log request body for POST/PUT requests (excluding sensitive data)
|
if requestBody != "" {
|
||||||
if c.Request.Method == "POST" || c.Request.Method == "PUT" {
|
fields = append(fields, zap.String("request_body", requestBody))
|
||||||
body := logRequestBody(c)
|
|
||||||
if body != "" {
|
|
||||||
logEntry["request_body"] = body
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log response size
|
|
||||||
if c.Writer.Size() > 0 {
|
if c.Writer.Size() > 0 {
|
||||||
logEntry["response_size"] = c.Writer.Size()
|
fields = append(fields, zap.Int("response_size", c.Writer.Size()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log errors
|
|
||||||
if len(c.Errors) > 0 {
|
if len(c.Errors) > 0 {
|
||||||
logEntry["errors"] = c.Errors.String()
|
fields = append(fields, zap.String("errors", c.Errors.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write structured log
|
// Log based on status code
|
||||||
logJSON(logEntry)
|
statusCode := c.Writer.Status()
|
||||||
|
if statusCode >= 500 {
|
||||||
|
logger.Error("HTTP request", fields...)
|
||||||
|
} else if statusCode >= 400 {
|
||||||
|
logger.Warn("HTTP request", fields...)
|
||||||
|
} else {
|
||||||
|
logger.Info("HTTP request", fields...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,17 +190,6 @@ func logRequestBody(c *gin.Context) string {
|
|||||||
return string(bodyBytes)
|
return string(bodyBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// logJSON writes structured JSON logs
|
|
||||||
func logJSON(data map[string]interface{}) {
|
|
||||||
jsonData, err := json.Marshal(data)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to marshal log entry: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println(string(jsonData))
|
|
||||||
}
|
|
||||||
|
|
||||||
// SecurityLogger logs security-related events
|
// SecurityLogger logs security-related events
|
||||||
func SecurityLogger() gin.HandlerFunc {
|
func SecurityLogger() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
@@ -232,19 +217,21 @@ func SecurityLogger() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// logSecurityEvent logs security-related events
|
// logSecurityEvent logs security-related events using Zap
|
||||||
func logSecurityEvent(eventType string, data map[string]interface{}) {
|
func logSecurityEvent(eventType string, data map[string]interface{}) {
|
||||||
event := map[string]interface{}{
|
logger := config.GetLogger()
|
||||||
"event_type": "security",
|
|
||||||
"event": eventType,
|
fields := []zap.Field{
|
||||||
"timestamp": time.Now().Format(time.RFC3339),
|
zap.String("event_type", "security"),
|
||||||
|
zap.String("event", eventType),
|
||||||
|
zap.String("timestamp", time.Now().Format(time.RFC3339)),
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range data {
|
for k, v := range data {
|
||||||
event[k] = v
|
fields = append(fields, zap.Any(k, v))
|
||||||
}
|
}
|
||||||
|
|
||||||
logJSON(event)
|
logger.Warn("Security event", fields...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PerformanceLogger logs performance metrics
|
// PerformanceLogger logs performance metrics
|
||||||
@@ -269,17 +256,19 @@ func PerformanceLogger() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// logPerformanceEvent logs performance-related events
|
// logPerformanceEvent logs performance-related events using Zap
|
||||||
func logPerformanceEvent(eventType string, data map[string]interface{}) {
|
func logPerformanceEvent(eventType string, data map[string]interface{}) {
|
||||||
event := map[string]interface{}{
|
logger := config.GetLogger()
|
||||||
"event_type": "performance",
|
|
||||||
"event": eventType,
|
fields := []zap.Field{
|
||||||
"timestamp": time.Now().Format(time.RFC3339),
|
zap.String("event_type", "performance"),
|
||||||
|
zap.String("event", eventType),
|
||||||
|
zap.String("timestamp", time.Now().Format(time.RFC3339)),
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range data {
|
for k, v := range data {
|
||||||
event[k] = v
|
fields = append(fields, zap.Any(k, v))
|
||||||
}
|
}
|
||||||
|
|
||||||
logJSON(event)
|
logger.Info("Performance event", fields...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Users table
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
first_name VARCHAR(100),
|
||||||
|
last_name VARCHAR(100),
|
||||||
|
avatar_url TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
is_verified BOOLEAN DEFAULT false,
|
||||||
|
last_login TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tags table
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
color VARCHAR(7) DEFAULT '#39b9ff',
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(name, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Bookmarks table
|
||||||
|
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
favicon_url TEXT,
|
||||||
|
screenshot_url TEXT,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
is_archived BOOLEAN DEFAULT false,
|
||||||
|
is_favorite BOOLEAN DEFAULT false,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Bookmark tags junction table
|
||||||
|
CREATE TABLE IF NOT EXISTS bookmark_tags (
|
||||||
|
bookmark_id UUID REFERENCES bookmarks(id) ON DELETE CASCADE,
|
||||||
|
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (bookmark_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tasks table
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed')),
|
||||||
|
priority VARCHAR(10) DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high')),
|
||||||
|
due_date TIMESTAMP,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Task tags junction table
|
||||||
|
CREATE TABLE IF NOT EXISTS task_tags (
|
||||||
|
task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (task_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Notes table
|
||||||
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Note tags junction table
|
||||||
|
CREATE TABLE IF NOT EXISTS note_tags (
|
||||||
|
note_id UUID REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (note_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Files table
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
original_name VARCHAR(255) NOT NULL,
|
||||||
|
file_size BIGINT NOT NULL,
|
||||||
|
mime_type VARCHAR(100),
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
thumbnail_path TEXT,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- File tags junction table
|
||||||
|
CREATE TABLE IF NOT EXISTS file_tags (
|
||||||
|
file_id UUID REFERENCES files(id) ON DELETE CASCADE,
|
||||||
|
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (file_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Audit logs table
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
action VARCHAR(100) NOT NULL,
|
||||||
|
resource_type VARCHAR(50) NOT NULL,
|
||||||
|
resource_id UUID,
|
||||||
|
old_values JSONB,
|
||||||
|
new_values JSONB,
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for better performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tags_user_id ON tags(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bookmarks_user_id ON bookmarks(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bookmarks_url ON bookmarks(url);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_files_user_id ON files(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- Drop tables in reverse order due to foreign key constraints
|
||||||
|
DROP TABLE IF EXISTS file_tags;
|
||||||
|
DROP TABLE IF EXISTS note_tags;
|
||||||
|
DROP TABLE IF EXISTS task_tags;
|
||||||
|
DROP TABLE IF EXISTS bookmark_tags;
|
||||||
|
DROP TABLE IF EXISTS audit_logs;
|
||||||
|
DROP TABLE IF EXISTS files;
|
||||||
|
DROP TABLE IF EXISTS notes;
|
||||||
|
DROP TABLE IF EXISTS tasks;
|
||||||
|
DROP TABLE IF EXISTS bookmarks;
|
||||||
|
DROP TABLE IF EXISTS tags;
|
||||||
|
DROP TABLE IF EXISTS users;
|
||||||