mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-03 20:12:58 +00:00
first test
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# 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
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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
|
||||
@@ -0,0 +1,50 @@
|
||||
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"]
|
||||
@@ -0,0 +1,66 @@
|
||||
# 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! 🎉
|
||||
@@ -0,0 +1,283 @@
|
||||
# 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!
|
||||
@@ -0,0 +1,308 @@
|
||||
# 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. 🚀
|
||||
@@ -0,0 +1,198 @@
|
||||
# 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.
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,49 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
oauth-service:
|
||||
build: ./oauth-service
|
||||
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:
|
||||
- ./oauth-service/.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
|
||||
@@ -0,0 +1,39 @@
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
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=
|
||||
@@ -0,0 +1,12 @@
|
||||
<!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>
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+2490
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/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"
|
||||
@@ -0,0 +1,18 @@
|
||||
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;
|
||||
@@ -0,0 +1,537 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,262 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,388 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** @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: [],
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user