client improvements
This commit is contained in:
parent
88905a2684
commit
98b6bd9313
360
client/README.md
360
client/README.md
@ -1,208 +1,272 @@
|
||||
# SEREACT Web Client
|
||||
# SeReact Frontend Client
|
||||
|
||||
A comprehensive web interface for the SEREACT API, providing full CRUD operations for teams, users, images, and API keys, along with AI-powered image search capabilities.
|
||||
A modern, responsive web frontend for the SeReact AI-powered image management platform. This is a pure frontend application that communicates directly with your SeReact backend API.
|
||||
|
||||
## Features
|
||||
|
||||
- **Configuration Management**: Set API endpoint and authentication
|
||||
- **Bootstrap Setup**: Initial system setup with team and admin user creation
|
||||
- **Teams Management**: Create, view, edit, and delete teams
|
||||
- **Users Management**: Create, view, edit, and delete users with role management
|
||||
- **API Keys Management**: Create and manage API keys for authentication
|
||||
- **Pure Frontend**: No backend dependencies - runs entirely in the browser
|
||||
- **Modern UI**: Built with Bootstrap 5 and modern CSS features
|
||||
- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices
|
||||
- **Real-time Updates**: Live connection status and automatic data refresh
|
||||
- **Keyboard Shortcuts**: Power-user friendly navigation and actions
|
||||
- **Offline Awareness**: Graceful handling of network connectivity issues
|
||||
|
||||
### Core Functionality
|
||||
|
||||
- **Configuration Management**: Easy API endpoint and authentication setup
|
||||
- **Image Management**: Upload, view, edit, and delete images with metadata
|
||||
- **AI-Powered Search**: Semantic image search using natural language queries
|
||||
- **Responsive Design**: Modern Bootstrap-based UI that works on all devices
|
||||
- **Team Management**: Create and manage teams and user access
|
||||
- **User Management**: Full user lifecycle with role-based permissions
|
||||
- **API Key Management**: Generate and manage API keys for integrations
|
||||
|
||||
## Prerequisites
|
||||
## Quick Start
|
||||
|
||||
- Python 3.8+
|
||||
- Running SEREACT API server
|
||||
- Network access to the SEREACT API
|
||||
### Prerequisites
|
||||
|
||||
## Installation
|
||||
- A running SeReact backend API server
|
||||
- Modern web browser (Chrome, Firefox, Safari, Edge)
|
||||
- Web server to serve static files (optional for development)
|
||||
|
||||
1. Navigate to the client directory:
|
||||
### Installation
|
||||
|
||||
1. **Download/Clone the frontend files**:
|
||||
```bash
|
||||
cd client
|
||||
# If you have the full SeReact repository
|
||||
cd sereact/client
|
||||
|
||||
# Or download just the client folder
|
||||
```
|
||||
|
||||
2. Create and activate a virtual environment:
|
||||
2. **Serve the files**:
|
||||
|
||||
**Option A: Simple HTTP Server (Python)**
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/macOS
|
||||
venv\Scripts\activate # Windows
|
||||
# Python 3
|
||||
python -m http.server 8080
|
||||
|
||||
# Python 2
|
||||
python -m SimpleHTTPServer 8080
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
|
||||
**Option B: Node.js HTTP Server**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
npx http-server -p 8080
|
||||
```
|
||||
|
||||
**Option C: Any web server**
|
||||
- Copy files to your web server directory
|
||||
- Ensure `index.html` is accessible
|
||||
|
||||
3. **Open in browser**:
|
||||
```
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
## Running the Client
|
||||
|
||||
1. Start the Flask development server:
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
2. Open your browser and navigate to:
|
||||
```
|
||||
http://localhost:5000
|
||||
```
|
||||
|
||||
## Initial Setup
|
||||
|
||||
### First Time Configuration
|
||||
### First-Time Setup
|
||||
|
||||
1. **Configure API Connection**:
|
||||
- Go to the "Config" page
|
||||
- Set your SEREACT API base URL (e.g., `http://localhost:8000`)
|
||||
- If you have an API key, enter it here
|
||||
- Click "Configure Now" in the welcome dialog
|
||||
- Enter your SeReact API base URL (e.g., `http://localhost:8000`)
|
||||
- Enter your API key
|
||||
- Test the connection
|
||||
|
||||
2. **Bootstrap the System** (if this is a new SEREACT installation):
|
||||
- Go to the "Bootstrap" page
|
||||
- Enter your organization details
|
||||
- Create the initial admin user
|
||||
- The system will generate an API key automatically
|
||||
|
||||
3. **Start Using the System**:
|
||||
- Upload images
|
||||
- Search for images using AI
|
||||
2. **Start Using**:
|
||||
- Upload your first images
|
||||
- Try the AI-powered search
|
||||
- Manage teams and users
|
||||
- Create additional API keys
|
||||
|
||||
## Usage Guide
|
||||
|
||||
### Teams Management
|
||||
- **View Teams**: See all teams in your organization
|
||||
- **Create Team**: Add new teams with descriptions
|
||||
- **Edit Team**: Update team information
|
||||
- **Delete Team**: Remove teams (admin only)
|
||||
|
||||
### Users Management
|
||||
- **View Users**: See all users in your team/organization
|
||||
- **Create User**: Add new users and assign them to teams
|
||||
- **Edit User**: Update user information and admin status
|
||||
- **Delete User**: Remove users from the system
|
||||
|
||||
### API Keys Management
|
||||
- **View API Keys**: See all your API keys and their status
|
||||
- **Create API Key**: Generate new API keys for applications
|
||||
- **Revoke API Key**: Disable API keys that are no longer needed
|
||||
|
||||
### Image Management
|
||||
- **Upload Images**: Add new images with descriptions and tags
|
||||
- **Browse Images**: View all uploaded images with filtering
|
||||
- **View Image Details**: See full image information and metadata
|
||||
- **Edit Images**: Update descriptions and tags
|
||||
- **Delete Images**: Remove images from storage
|
||||
|
||||
### AI-Powered Search
|
||||
- **Semantic Search**: Use natural language to find images
|
||||
- **Advanced Options**: Adjust similarity thresholds and result limits
|
||||
- **Filter Results**: Combine search with tag filtering
|
||||
- **Similarity Scores**: See how closely images match your query
|
||||
|
||||
## Configuration
|
||||
|
||||
### API Settings
|
||||
|
||||
The frontend stores configuration in browser localStorage:
|
||||
|
||||
- **API Base URL**: The URL of your SeReact backend (e.g., `http://localhost:8000`)
|
||||
- **API Key**: Your authentication key for the backend API
|
||||
|
||||
### Environment Variables
|
||||
|
||||
You can set these environment variables to configure default values:
|
||||
For deployment, you can set default values by modifying `js/config.js`:
|
||||
|
||||
```bash
|
||||
export SEREACT_API_URL=http://localhost:8000
|
||||
export SEREACT_API_KEY=your-api-key
|
||||
```javascript
|
||||
// Default configuration
|
||||
this.apiBaseUrl = localStorage.getItem('apiBaseUrl') || 'https://your-api.example.com';
|
||||
this.apiKey = localStorage.getItem('apiKey') || '';
|
||||
```
|
||||
|
||||
### Session Storage
|
||||
## File Structure
|
||||
|
||||
The client stores configuration in the browser session:
|
||||
- API base URL
|
||||
- API key
|
||||
- These are not persistent and need to be re-entered after browser restart
|
||||
```
|
||||
client/
|
||||
├── index.html # Main application entry point
|
||||
├── styles.css # Custom CSS styles
|
||||
├── js/ # JavaScript modules
|
||||
│ ├── config.js # Configuration management
|
||||
│ ├── api.js # API client and HTTP requests
|
||||
│ ├── ui.js # UI utilities and common functions
|
||||
│ ├── images.js # Image management functionality
|
||||
│ ├── search.js # AI search functionality
|
||||
│ ├── teams.js # Team management
|
||||
│ ├── users.js # User management
|
||||
│ ├── apikeys.js # API key management
|
||||
│ └── app.js # Main application initialization
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## API Integration
|
||||
## Features in Detail
|
||||
|
||||
The client integrates with the following SEREACT API endpoints:
|
||||
### Image Management
|
||||
- **Upload**: Drag & drop or click to upload images
|
||||
- **Metadata**: Add descriptions and tags to images
|
||||
- **View**: Full-size image viewing with details
|
||||
- **Edit**: Update descriptions and tags
|
||||
- **Delete**: Remove images with confirmation
|
||||
|
||||
- `/api/v1/auth/*` - Authentication and API key management
|
||||
- `/api/v1/teams/*` - Team management
|
||||
- `/api/v1/users/*` - User management
|
||||
- `/api/v1/images/*` - Image upload and management
|
||||
- `/api/v1/search/*` - AI-powered image search
|
||||
### AI-Powered Search
|
||||
- **Natural Language**: Search using descriptive text
|
||||
- **Similarity Threshold**: Adjust search sensitivity
|
||||
- **Result Filtering**: Filter by tags and metadata
|
||||
- **Search History**: Save and reuse frequent searches
|
||||
|
||||
## Security Notes
|
||||
### Team & User Management
|
||||
- **Teams**: Create and manage organizational teams
|
||||
- **Users**: Add users with role-based permissions
|
||||
- **Admin Controls**: Administrative functions for system management
|
||||
|
||||
- **API Keys**: Store API keys securely and never share them
|
||||
### API Key Management
|
||||
- **Generate Keys**: Create API keys for integrations
|
||||
- **Security**: One-time display of new keys
|
||||
- **Usage Tracking**: Monitor key usage and activity
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
- **Ctrl+K**: Open search page and focus search input
|
||||
- **Ctrl+U**: Upload new image
|
||||
- **Ctrl+,**: Open configuration page
|
||||
- **1-6**: Navigate to different pages (Home, Images, Search, Teams, Users, API Keys)
|
||||
- **Esc**: Close open modals
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
- **Chrome**: 80+
|
||||
- **Firefox**: 75+
|
||||
- **Safari**: 13+
|
||||
- **Edge**: 80+
|
||||
|
||||
### Required Features
|
||||
- ES6+ JavaScript support
|
||||
- Fetch API
|
||||
- CSS Grid and Flexbox
|
||||
- Local Storage
|
||||
|
||||
## Deployment
|
||||
|
||||
### Static Hosting
|
||||
|
||||
The frontend can be deployed to any static hosting service:
|
||||
|
||||
- **Netlify**: Drag and drop the client folder
|
||||
- **Vercel**: Connect your repository
|
||||
- **GitHub Pages**: Push to a gh-pages branch
|
||||
- **AWS S3**: Upload files to an S3 bucket with static hosting
|
||||
- **Azure Static Web Apps**: Deploy via GitHub integration
|
||||
|
||||
### Web Server Configuration
|
||||
|
||||
For proper routing with hash-based navigation, no special server configuration is needed. For history-based routing (if implemented), configure your server to serve `index.html` for all routes.
|
||||
|
||||
### CORS Configuration
|
||||
|
||||
Ensure your SeReact backend API is configured to allow requests from your frontend domain:
|
||||
|
||||
```python
|
||||
# In your backend CORS configuration
|
||||
ALLOWED_ORIGINS = [
|
||||
"http://localhost:8080",
|
||||
"https://your-frontend-domain.com"
|
||||
]
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Local Development
|
||||
|
||||
1. **Start a local server**:
|
||||
```bash
|
||||
python -m http.server 8080
|
||||
```
|
||||
|
||||
2. **Open browser with dev tools**:
|
||||
```
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
3. **Make changes**: Edit files and refresh browser
|
||||
|
||||
### Debugging
|
||||
|
||||
- Open browser developer tools (F12)
|
||||
- Check console for error messages
|
||||
- Use Network tab to monitor API requests
|
||||
- Application tab shows localStorage configuration
|
||||
|
||||
### Adding Features
|
||||
|
||||
The modular structure makes it easy to add new features:
|
||||
|
||||
1. Create new JavaScript module in `js/` folder
|
||||
2. Add corresponding HTML section to `index.html`
|
||||
3. Include script tag in `index.html`
|
||||
4. Add navigation link if needed
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **API Keys**: Stored in browser localStorage (consider security implications)
|
||||
- **HTTPS**: Use HTTPS in production for secure communication
|
||||
- **Access Control**: The client respects the API's team-based access control
|
||||
- **Session Security**: Configure secure session cookies in production
|
||||
- **CORS**: Properly configure backend CORS settings
|
||||
- **Content Security Policy**: Consider implementing CSP headers
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Connection Errors**:
|
||||
- Verify the API base URL is correct
|
||||
- Ensure the SEREACT API server is running
|
||||
- Check network connectivity
|
||||
- Verify API base URL is correct
|
||||
- Check if backend server is running
|
||||
- Ensure CORS is properly configured
|
||||
|
||||
2. **Authentication Errors**:
|
||||
- Verify your API key is valid and active
|
||||
- Check if the API key has expired
|
||||
- Ensure you have the necessary permissions
|
||||
- Verify API key is valid and active
|
||||
- Check API key permissions
|
||||
- Ensure key hasn't expired
|
||||
|
||||
3. **Image Upload Issues**:
|
||||
- Check file size (max 10MB)
|
||||
- Check file size limits (default 10MB)
|
||||
- Verify file format is supported
|
||||
- Ensure sufficient storage space
|
||||
- Ensure sufficient backend storage
|
||||
|
||||
4. **Search Not Working**:
|
||||
- Verify images have been processed (have embeddings)
|
||||
- Check if the vector database is configured
|
||||
- Try adjusting similarity thresholds
|
||||
- Verify images have been processed
|
||||
- Check vector database configuration
|
||||
- Try adjusting similarity threshold
|
||||
|
||||
### Error Messages
|
||||
|
||||
- **"API key not set"**: Configure your API key in the Config page
|
||||
- **"Request failed"**: Check API server status and network connection
|
||||
- **"Invalid JSON response"**: API server may be returning errors
|
||||
- **"API not configured"**: Set up API base URL and key in configuration
|
||||
- **"Connection failed"**: Check network and backend server status
|
||||
- **"Authentication failed"**: Verify API key is correct and active
|
||||
- **"File size exceeds limit"**: Choose a smaller image file
|
||||
|
||||
## Development
|
||||
## Support
|
||||
|
||||
### Project Structure
|
||||
For issues and questions:
|
||||
|
||||
```
|
||||
client/
|
||||
├── app.py # Main Flask application
|
||||
├── requirements.txt # Python dependencies
|
||||
├── templates/ # HTML templates
|
||||
│ ├── base.html # Base template with navigation
|
||||
│ ├── index.html # Home page
|
||||
│ ├── config.html # Configuration page
|
||||
│ ├── bootstrap.html # Bootstrap setup page
|
||||
│ ├── teams.html # Teams listing
|
||||
│ ├── team_form.html # Team create/edit form
|
||||
│ ├── users.html # Users listing
|
||||
│ ├── user_form.html # User create/edit form
|
||||
│ ├── api_keys.html # API keys listing
|
||||
│ ├── api_key_form.html # API key creation form
|
||||
│ ├── images.html # Images listing with pagination
|
||||
│ ├── image_upload.html # Image upload form
|
||||
│ ├── image_detail.html # Image detail view
|
||||
│ ├── image_edit.html # Image edit form
|
||||
│ └── search.html # AI search interface
|
||||
└── uploads/ # Temporary upload directory
|
||||
```
|
||||
|
||||
### Customization
|
||||
|
||||
- **Styling**: Modify the CSS in `templates/base.html`
|
||||
- **Features**: Add new routes and templates in `app.py`
|
||||
- **API Integration**: Extend the `make_api_request` function
|
||||
- **UI Components**: Use Bootstrap classes for consistent styling
|
||||
1. Check the browser console for error messages
|
||||
2. Verify backend API is running and accessible
|
||||
3. Check network connectivity and CORS configuration
|
||||
4. Review the SeReact backend documentation
|
||||
|
||||
## License
|
||||
|
||||
This web client is part of the SEREACT project and follows the same license terms.
|
||||
This frontend client is part of the SeReact project. See the main project license for details.
|
||||
423
client/app.py
423
client/app.py
@ -1,423 +0,0 @@
|
||||
import os
|
||||
import requests
|
||||
from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify
|
||||
from werkzeug.utils import secure_filename
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = 'your-secret-key-change-in-production'
|
||||
|
||||
# Configuration
|
||||
UPLOAD_FOLDER = 'uploads'
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'}
|
||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 # 10MB max file size
|
||||
|
||||
# Ensure upload folder exists
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
def get_api_base_url():
|
||||
return session.get('api_base_url', 'http://localhost:8000')
|
||||
|
||||
def get_api_key():
|
||||
return session.get('api_key')
|
||||
|
||||
def make_api_request(method, endpoint, data=None, files=None, params=None):
|
||||
"""Make API request with proper headers"""
|
||||
api_key = get_api_key()
|
||||
if not api_key:
|
||||
return None, "API key not set"
|
||||
|
||||
headers = {'X-API-Key': api_key}
|
||||
url = f"{get_api_base_url()}/api/v1{endpoint}"
|
||||
|
||||
try:
|
||||
if method == 'GET':
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
elif method == 'POST':
|
||||
if files:
|
||||
response = requests.post(url, headers=headers, data=data, files=files)
|
||||
else:
|
||||
headers['Content-Type'] = 'application/json'
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
elif method == 'PUT':
|
||||
headers['Content-Type'] = 'application/json'
|
||||
response = requests.put(url, headers=headers, json=data)
|
||||
elif method == 'DELETE':
|
||||
response = requests.delete(url, headers=headers)
|
||||
else:
|
||||
return None, f"Unsupported method: {method}"
|
||||
|
||||
if response.status_code == 204:
|
||||
return True, None
|
||||
|
||||
return response.json(), None if response.status_code < 400 else response.json().get('detail', 'Unknown error')
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
return None, f"Request failed: {str(e)}"
|
||||
except json.JSONDecodeError:
|
||||
return None, "Invalid JSON response"
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/config', methods=['GET', 'POST'])
|
||||
def config():
|
||||
if request.method == 'POST':
|
||||
api_base_url = request.form.get('api_base_url', '').strip()
|
||||
api_key = request.form.get('api_key', '').strip()
|
||||
|
||||
if api_base_url:
|
||||
session['api_base_url'] = api_base_url.rstrip('/')
|
||||
if api_key:
|
||||
session['api_key'] = api_key
|
||||
|
||||
flash('Configuration updated successfully!', 'success')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
return render_template('config.html',
|
||||
api_base_url=get_api_base_url(),
|
||||
api_key=get_api_key())
|
||||
|
||||
# Teams CRUD
|
||||
@app.route('/teams')
|
||||
def teams():
|
||||
data, error = make_api_request('GET', '/teams')
|
||||
if error:
|
||||
flash(f'Error loading teams: {error}', 'error')
|
||||
return render_template('teams.html', teams=[])
|
||||
|
||||
return render_template('teams.html', teams=data.get('teams', []))
|
||||
|
||||
@app.route('/teams/create', methods=['GET', 'POST'])
|
||||
def create_team():
|
||||
if request.method == 'POST':
|
||||
team_data = {
|
||||
'name': request.form.get('name'),
|
||||
'description': request.form.get('description', '')
|
||||
}
|
||||
|
||||
data, error = make_api_request('POST', '/teams', team_data)
|
||||
if error:
|
||||
flash(f'Error creating team: {error}', 'error')
|
||||
else:
|
||||
flash('Team created successfully!', 'success')
|
||||
return redirect(url_for('teams'))
|
||||
|
||||
return render_template('team_form.html', team=None, action='Create')
|
||||
|
||||
@app.route('/teams/<team_id>/edit', methods=['GET', 'POST'])
|
||||
def edit_team(team_id):
|
||||
if request.method == 'POST':
|
||||
team_data = {
|
||||
'name': request.form.get('name'),
|
||||
'description': request.form.get('description', '')
|
||||
}
|
||||
|
||||
data, error = make_api_request('PUT', f'/teams/{team_id}', team_data)
|
||||
if error:
|
||||
flash(f'Error updating team: {error}', 'error')
|
||||
else:
|
||||
flash('Team updated successfully!', 'success')
|
||||
return redirect(url_for('teams'))
|
||||
|
||||
# Get team data for editing
|
||||
data, error = make_api_request('GET', f'/teams/{team_id}')
|
||||
if error:
|
||||
flash(f'Error loading team: {error}', 'error')
|
||||
return redirect(url_for('teams'))
|
||||
|
||||
return render_template('team_form.html', team=data, action='Edit')
|
||||
|
||||
@app.route('/teams/<team_id>/delete', methods=['POST'])
|
||||
def delete_team(team_id):
|
||||
data, error = make_api_request('DELETE', f'/teams/{team_id}')
|
||||
if error:
|
||||
flash(f'Error deleting team: {error}', 'error')
|
||||
else:
|
||||
flash('Team deleted successfully!', 'success')
|
||||
|
||||
return redirect(url_for('teams'))
|
||||
|
||||
# Users CRUD
|
||||
@app.route('/users')
|
||||
def users():
|
||||
data, error = make_api_request('GET', '/users')
|
||||
if error:
|
||||
flash(f'Error loading users: {error}', 'error')
|
||||
return render_template('users.html', users=[])
|
||||
|
||||
return render_template('users.html', users=data.get('users', []))
|
||||
|
||||
@app.route('/users/create', methods=['GET', 'POST'])
|
||||
def create_user():
|
||||
if request.method == 'POST':
|
||||
user_data = {
|
||||
'name': request.form.get('name'),
|
||||
'email': request.form.get('email'),
|
||||
'team_id': request.form.get('team_id') or None,
|
||||
'is_admin': request.form.get('is_admin') == 'on'
|
||||
}
|
||||
|
||||
data, error = make_api_request('POST', '/users', user_data)
|
||||
if error:
|
||||
flash(f'Error creating user: {error}', 'error')
|
||||
else:
|
||||
flash('User created successfully!', 'success')
|
||||
return redirect(url_for('users'))
|
||||
|
||||
# Get teams for dropdown
|
||||
teams_data, teams_error = make_api_request('GET', '/teams')
|
||||
teams = teams_data.get('teams', []) if teams_data else []
|
||||
|
||||
return render_template('user_form.html', user=None, teams=teams, action='Create')
|
||||
|
||||
@app.route('/users/<user_id>/edit', methods=['GET', 'POST'])
|
||||
def edit_user(user_id):
|
||||
if request.method == 'POST':
|
||||
user_data = {
|
||||
'name': request.form.get('name'),
|
||||
'email': request.form.get('email'),
|
||||
'is_admin': request.form.get('is_admin') == 'on'
|
||||
}
|
||||
|
||||
data, error = make_api_request('PUT', f'/users/{user_id}', user_data)
|
||||
if error:
|
||||
flash(f'Error updating user: {error}', 'error')
|
||||
else:
|
||||
flash('User updated successfully!', 'success')
|
||||
return redirect(url_for('users'))
|
||||
|
||||
# Get user data for editing
|
||||
data, error = make_api_request('GET', f'/users/{user_id}')
|
||||
if error:
|
||||
flash(f'Error loading user: {error}', 'error')
|
||||
return redirect(url_for('users'))
|
||||
|
||||
# Get teams for dropdown
|
||||
teams_data, teams_error = make_api_request('GET', '/teams')
|
||||
teams = teams_data.get('teams', []) if teams_data else []
|
||||
|
||||
return render_template('user_form.html', user=data, teams=teams, action='Edit')
|
||||
|
||||
@app.route('/users/<user_id>/delete', methods=['POST'])
|
||||
def delete_user(user_id):
|
||||
data, error = make_api_request('DELETE', f'/users/{user_id}')
|
||||
if error:
|
||||
flash(f'Error deleting user: {error}', 'error')
|
||||
else:
|
||||
flash('User deleted successfully!', 'success')
|
||||
|
||||
return redirect(url_for('users'))
|
||||
|
||||
# API Keys CRUD
|
||||
@app.route('/api-keys')
|
||||
def api_keys():
|
||||
data, error = make_api_request('GET', '/auth/api-keys')
|
||||
if error:
|
||||
flash(f'Error loading API keys: {error}', 'error')
|
||||
return render_template('api_keys.html', api_keys=[])
|
||||
|
||||
return render_template('api_keys.html', api_keys=data.get('api_keys', []))
|
||||
|
||||
@app.route('/api-keys/create', methods=['GET', 'POST'])
|
||||
def create_api_key():
|
||||
if request.method == 'POST':
|
||||
key_data = {
|
||||
'name': request.form.get('name'),
|
||||
'description': request.form.get('description', ''),
|
||||
'team_id': request.form.get('team_id') or None,
|
||||
'user_id': request.form.get('user_id') or None
|
||||
}
|
||||
|
||||
data, error = make_api_request('POST', '/auth/api-keys', key_data)
|
||||
if error:
|
||||
flash(f'Error creating API key: {error}', 'error')
|
||||
else:
|
||||
flash(f'API key created successfully! Key: {data.get("key")}', 'success')
|
||||
return redirect(url_for('api_keys'))
|
||||
|
||||
# Get teams and users for dropdowns
|
||||
teams_data, _ = make_api_request('GET', '/teams')
|
||||
users_data, _ = make_api_request('GET', '/users')
|
||||
teams = teams_data.get('teams', []) if teams_data else []
|
||||
users = users_data.get('users', []) if users_data else []
|
||||
|
||||
return render_template('api_key_form.html', teams=teams, users=users)
|
||||
|
||||
@app.route('/api-keys/<key_id>/delete', methods=['POST'])
|
||||
def delete_api_key(key_id):
|
||||
data, error = make_api_request('DELETE', f'/auth/api-keys/{key_id}')
|
||||
if error:
|
||||
flash(f'Error deleting API key: {error}', 'error')
|
||||
else:
|
||||
flash('API key deleted successfully!', 'success')
|
||||
|
||||
return redirect(url_for('api_keys'))
|
||||
|
||||
# Images CRUD
|
||||
@app.route('/images')
|
||||
def images():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
limit = 20
|
||||
skip = (page - 1) * limit
|
||||
|
||||
params = {'skip': skip, 'limit': limit}
|
||||
tags = request.args.get('tags')
|
||||
if tags:
|
||||
params['tags'] = tags
|
||||
|
||||
data, error = make_api_request('GET', '/images', params=params)
|
||||
if error:
|
||||
flash(f'Error loading images: {error}', 'error')
|
||||
return render_template('images.html', images=[], total=0, page=page, limit=limit)
|
||||
|
||||
return render_template('images.html',
|
||||
images=data.get('images', []),
|
||||
total=data.get('total', 0),
|
||||
page=page,
|
||||
limit=limit)
|
||||
|
||||
@app.route('/images/upload', methods=['GET', 'POST'])
|
||||
def upload_image():
|
||||
if request.method == 'POST':
|
||||
if 'file' not in request.files:
|
||||
flash('No file selected', 'error')
|
||||
return redirect(request.url)
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
flash('No file selected', 'error')
|
||||
return redirect(request.url)
|
||||
|
||||
if file and allowed_file(file.filename):
|
||||
filename = secure_filename(file.filename)
|
||||
|
||||
# Prepare form data
|
||||
form_data = {
|
||||
'description': request.form.get('description', ''),
|
||||
'tags': request.form.get('tags', ''),
|
||||
'collection_id': request.form.get('collection_id') or None
|
||||
}
|
||||
|
||||
# Prepare files
|
||||
files = {'file': (filename, file, file.content_type)}
|
||||
|
||||
data, error = make_api_request('POST', '/images', data=form_data, files=files)
|
||||
if error:
|
||||
flash(f'Error uploading image: {error}', 'error')
|
||||
else:
|
||||
flash('Image uploaded successfully!', 'success')
|
||||
return redirect(url_for('images'))
|
||||
else:
|
||||
flash('Invalid file type. Please upload an image file.', 'error')
|
||||
|
||||
return render_template('image_upload.html')
|
||||
|
||||
@app.route('/images/<image_id>')
|
||||
def view_image(image_id):
|
||||
data, error = make_api_request('GET', f'/images/{image_id}')
|
||||
if error:
|
||||
flash(f'Error loading image: {error}', 'error')
|
||||
return redirect(url_for('images'))
|
||||
|
||||
return render_template('image_detail.html', image=data)
|
||||
|
||||
@app.route('/images/<image_id>/edit', methods=['GET', 'POST'])
|
||||
def edit_image(image_id):
|
||||
if request.method == 'POST':
|
||||
image_data = {
|
||||
'description': request.form.get('description', ''),
|
||||
'tags': [tag.strip() for tag in request.form.get('tags', '').split(',') if tag.strip()]
|
||||
}
|
||||
|
||||
data, error = make_api_request('PUT', f'/images/{image_id}', image_data)
|
||||
if error:
|
||||
flash(f'Error updating image: {error}', 'error')
|
||||
else:
|
||||
flash('Image updated successfully!', 'success')
|
||||
return redirect(url_for('view_image', image_id=image_id))
|
||||
|
||||
# Get image data for editing
|
||||
data, error = make_api_request('GET', f'/images/{image_id}')
|
||||
if error:
|
||||
flash(f'Error loading image: {error}', 'error')
|
||||
return redirect(url_for('images'))
|
||||
|
||||
return render_template('image_edit.html', image=data)
|
||||
|
||||
@app.route('/images/<image_id>/delete', methods=['POST'])
|
||||
def delete_image(image_id):
|
||||
data, error = make_api_request('DELETE', f'/images/{image_id}')
|
||||
if error:
|
||||
flash(f'Error deleting image: {error}', 'error')
|
||||
else:
|
||||
flash('Image deleted successfully!', 'success')
|
||||
|
||||
return redirect(url_for('images'))
|
||||
|
||||
# Search functionality
|
||||
@app.route('/search', methods=['GET', 'POST'])
|
||||
def search():
|
||||
if request.method == 'POST':
|
||||
query = request.form.get('query', '').strip()
|
||||
limit = int(request.form.get('limit', 10))
|
||||
threshold = float(request.form.get('threshold', 0.7))
|
||||
tags = request.form.get('tags', '').strip()
|
||||
|
||||
params = {
|
||||
'q': query,
|
||||
'limit': limit,
|
||||
'threshold': threshold
|
||||
}
|
||||
if tags:
|
||||
params['tags'] = tags
|
||||
|
||||
data, error = make_api_request('GET', '/search', params=params)
|
||||
if error:
|
||||
flash(f'Search error: {error}', 'error')
|
||||
return render_template('search.html', results=[], query=query)
|
||||
|
||||
return render_template('search.html',
|
||||
results=data.get('results', []),
|
||||
query=query,
|
||||
total=data.get('total', 0))
|
||||
|
||||
return render_template('search.html', results=[], query='')
|
||||
|
||||
@app.route('/bootstrap', methods=['GET', 'POST'])
|
||||
def bootstrap():
|
||||
if request.method == 'POST':
|
||||
bootstrap_data = {
|
||||
'team_name': request.form.get('team_name'),
|
||||
'admin_email': request.form.get('admin_email'),
|
||||
'admin_name': request.form.get('admin_name'),
|
||||
'api_key_name': request.form.get('api_key_name', 'Initial API Key')
|
||||
}
|
||||
|
||||
# Make bootstrap request without API key
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
url = f"{get_api_base_url()}/api/v1/auth/bootstrap"
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=bootstrap_data)
|
||||
if response.status_code == 201:
|
||||
data = response.json()
|
||||
session['api_key'] = data.get('key')
|
||||
flash(f'Bootstrap successful! API Key: {data.get("key")}', 'success')
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
error_data = response.json()
|
||||
flash(f'Bootstrap error: {error_data.get("detail", "Unknown error")}', 'error')
|
||||
except Exception as e:
|
||||
flash(f'Bootstrap failed: {str(e)}', 'error')
|
||||
|
||||
return render_template('bootstrap.html')
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, port=5000)
|
||||
264
client/index.html
Normal file
264
client/index.html
Normal file
@ -0,0 +1,264 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SeReact - AI-Powered Image Management</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="styles.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#" onclick="showPage('home')">
|
||||
<i class="fas fa-search me-2"></i>SeReact
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" onclick="showPage('home')">
|
||||
<i class="fas fa-home me-1"></i>Home
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" onclick="showPage('images')">
|
||||
<i class="fas fa-images me-1"></i>Images
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" onclick="showPage('search')">
|
||||
<i class="fas fa-search me-1"></i>Search
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" onclick="showPage('teams')">
|
||||
<i class="fas fa-users me-1"></i>Teams
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" onclick="showPage('users')">
|
||||
<i class="fas fa-user me-1"></i>Users
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" onclick="showPage('api-keys')">
|
||||
<i class="fas fa-key me-1"></i>API Keys
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" onclick="showPage('config')">
|
||||
<i class="fas fa-cog me-1"></i>Config
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="container mt-4">
|
||||
<!-- Alert Container -->
|
||||
<div id="alertContainer"></div>
|
||||
|
||||
<!-- Home Page -->
|
||||
<div id="homePage" class="page">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="jumbotron bg-light p-5 rounded">
|
||||
<h1 class="display-4">Welcome to SeReact</h1>
|
||||
<p class="lead">AI-powered image management and semantic search platform</p>
|
||||
<hr class="my-4">
|
||||
<p>Upload images, manage your team, and search using natural language queries.</p>
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-upload fa-3x text-primary mb-3"></i>
|
||||
<h5 class="card-title">Upload Images</h5>
|
||||
<p class="card-text">Upload and manage your image collection with metadata and tags.</p>
|
||||
<button class="btn btn-primary" onclick="showPage('images')">Get Started</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-search fa-3x text-success mb-3"></i>
|
||||
<h5 class="card-title">AI Search</h5>
|
||||
<p class="card-text">Find images using natural language queries powered by AI.</p>
|
||||
<button class="btn btn-success" onclick="showPage('search')">Search Now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-users fa-3x text-info mb-3"></i>
|
||||
<h5 class="card-title">Team Management</h5>
|
||||
<p class="card-text">Manage teams, users, and access permissions.</p>
|
||||
<button class="btn btn-info" onclick="showPage('teams')">Manage</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Page -->
|
||||
<div id="configPage" class="page" style="display: none;">
|
||||
<div class="row">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-cog me-2"></i>Configuration</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="configForm">
|
||||
<div class="mb-3">
|
||||
<label for="apiBaseUrl" class="form-label">API Base URL</label>
|
||||
<input type="url" class="form-control" id="apiBaseUrl"
|
||||
placeholder="http://localhost:8000" required>
|
||||
<div class="form-text">The base URL of your SeReact API server</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="apiKey" class="form-label">API Key</label>
|
||||
<input type="password" class="form-control" id="apiKey"
|
||||
placeholder="Enter your API key">
|
||||
<div class="form-text">Your API key for authentication</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save me-1"></i>Save Configuration
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="testConnection()">
|
||||
<i class="fas fa-plug me-1"></i>Test Connection
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Images Page -->
|
||||
<div id="imagesPage" class="page" style="display: none;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-images me-2"></i>Images</h2>
|
||||
<button class="btn btn-primary" onclick="showUploadModal()">
|
||||
<i class="fas fa-plus me-1"></i>Upload Image
|
||||
</button>
|
||||
</div>
|
||||
<div id="imagesContainer">
|
||||
<!-- Images will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Page -->
|
||||
<div id="searchPage" class="page" style="display: none;">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h2><i class="fas fa-search me-2"></i>AI-Powered Image Search</h2>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form id="searchForm">
|
||||
<div class="mb-3">
|
||||
<label for="searchQuery" class="form-label">Search Query</label>
|
||||
<input type="text" class="form-control" id="searchQuery"
|
||||
placeholder="Describe what you're looking for..." required>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label for="similarityThreshold" class="form-label">Similarity Threshold</label>
|
||||
<input type="range" class="form-range" id="similarityThreshold"
|
||||
min="0" max="1" step="0.1" value="0.7">
|
||||
<div class="form-text">Current: <span id="thresholdValue">0.7</span></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="maxResults" class="form-label">Max Results</label>
|
||||
<select class="form-select" id="maxResults">
|
||||
<option value="10">10</option>
|
||||
<option value="20" selected>20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary mt-3">
|
||||
<i class="fas fa-search me-1"></i>Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div id="searchResults" class="mt-4">
|
||||
<!-- Search results will be displayed here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Teams Page -->
|
||||
<div id="teamsPage" class="page" style="display: none;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-users me-2"></i>Teams</h2>
|
||||
<button class="btn btn-primary" onclick="showCreateTeamModal()">
|
||||
<i class="fas fa-plus me-1"></i>Create Team
|
||||
</button>
|
||||
</div>
|
||||
<div id="teamsContainer">
|
||||
<!-- Teams will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Page -->
|
||||
<div id="usersPage" class="page" style="display: none;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-user me-2"></i>Users</h2>
|
||||
<button class="btn btn-primary" onclick="showCreateUserModal()">
|
||||
<i class="fas fa-plus me-1"></i>Create User
|
||||
</button>
|
||||
</div>
|
||||
<div id="usersContainer">
|
||||
<!-- Users will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Keys Page -->
|
||||
<div id="apiKeysPage" class="page" style="display: none;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-key me-2"></i>API Keys</h2>
|
||||
<button class="btn btn-primary" onclick="showCreateApiKeyModal()">
|
||||
<i class="fas fa-plus me-1"></i>Create API Key
|
||||
</button>
|
||||
</div>
|
||||
<div id="apiKeysContainer">
|
||||
<!-- API Keys will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modals will be added here -->
|
||||
<div id="modalContainer"></div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/images.js"></script>
|
||||
<script src="js/search.js"></script>
|
||||
<script src="js/teams.js"></script>
|
||||
<script src="js/users.js"></script>
|
||||
<script src="js/apikeys.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
212
client/js/api.js
Normal file
212
client/js/api.js
Normal file
@ -0,0 +1,212 @@
|
||||
// API client for making requests to the backend
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.baseUrl = '';
|
||||
this.apiKey = '';
|
||||
}
|
||||
|
||||
updateConfig() {
|
||||
this.baseUrl = config.getApiBaseUrl();
|
||||
this.apiKey = config.getApiKey();
|
||||
}
|
||||
|
||||
getHeaders(includeContentType = true) {
|
||||
const headers = {
|
||||
'X-API-Key': this.apiKey
|
||||
};
|
||||
|
||||
if (includeContentType) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
async makeRequest(method, endpoint, data = null, isFormData = false) {
|
||||
this.updateConfig();
|
||||
|
||||
if (!this.baseUrl || !this.apiKey) {
|
||||
throw new Error('API not configured. Please set API base URL and key.');
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}/api/v1${endpoint}`;
|
||||
|
||||
const options = {
|
||||
method,
|
||||
headers: this.getHeaders(!isFormData)
|
||||
};
|
||||
|
||||
if (data) {
|
||||
if (isFormData) {
|
||||
options.body = data; // FormData object
|
||||
} else {
|
||||
options.body = JSON.stringify(data);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
// Handle different response types
|
||||
if (response.status === 204) {
|
||||
return null; // No content
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
let responseData;
|
||||
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
responseData = await response.json();
|
||||
} else {
|
||||
responseData = await response.text();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = responseData?.detail || responseData?.message || `HTTP ${response.status}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
} catch (error) {
|
||||
console.error(`API request failed: ${method} ${url}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Teams API
|
||||
async getTeams() {
|
||||
return this.makeRequest('GET', '/teams');
|
||||
}
|
||||
|
||||
async createTeam(teamData) {
|
||||
return this.makeRequest('POST', '/teams', teamData);
|
||||
}
|
||||
|
||||
async updateTeam(teamId, teamData) {
|
||||
return this.makeRequest('PUT', `/teams/${teamId}`, teamData);
|
||||
}
|
||||
|
||||
async deleteTeam(teamId) {
|
||||
return this.makeRequest('DELETE', `/teams/${teamId}`);
|
||||
}
|
||||
|
||||
// Users API
|
||||
async getUsers() {
|
||||
return this.makeRequest('GET', '/users');
|
||||
}
|
||||
|
||||
async createUser(userData) {
|
||||
return this.makeRequest('POST', '/users', userData);
|
||||
}
|
||||
|
||||
async updateUser(userId, userData) {
|
||||
return this.makeRequest('PUT', `/users/${userId}`, userData);
|
||||
}
|
||||
|
||||
async deleteUser(userId) {
|
||||
return this.makeRequest('DELETE', `/users/${userId}`);
|
||||
}
|
||||
|
||||
// API Keys API
|
||||
async getApiKeys() {
|
||||
return this.makeRequest('GET', '/auth/api-keys');
|
||||
}
|
||||
|
||||
async createApiKey(keyData) {
|
||||
return this.makeRequest('POST', '/auth/api-keys', keyData);
|
||||
}
|
||||
|
||||
async deleteApiKey(keyId) {
|
||||
return this.makeRequest('DELETE', `/auth/api-keys/${keyId}`);
|
||||
}
|
||||
|
||||
// Images API
|
||||
async getImages(page = 1, limit = 20, tags = null) {
|
||||
let endpoint = `/images?page=${page}&limit=${limit}`;
|
||||
if (tags) {
|
||||
endpoint += `&tags=${encodeURIComponent(tags)}`;
|
||||
}
|
||||
return this.makeRequest('GET', endpoint);
|
||||
}
|
||||
|
||||
async getImage(imageId) {
|
||||
return this.makeRequest('GET', `/images/${imageId}`);
|
||||
}
|
||||
|
||||
async uploadImage(formData) {
|
||||
return this.makeRequest('POST', '/images', formData, true);
|
||||
}
|
||||
|
||||
async updateImage(imageId, imageData) {
|
||||
return this.makeRequest('PUT', `/images/${imageId}`, imageData);
|
||||
}
|
||||
|
||||
async deleteImage(imageId) {
|
||||
return this.makeRequest('DELETE', `/images/${imageId}`);
|
||||
}
|
||||
|
||||
// Search API
|
||||
async searchImages(query, similarityThreshold = 0.7, maxResults = 20, tags = null) {
|
||||
const searchData = {
|
||||
query,
|
||||
similarity_threshold: similarityThreshold,
|
||||
max_results: maxResults
|
||||
};
|
||||
|
||||
if (tags) {
|
||||
searchData.tags = tags;
|
||||
}
|
||||
|
||||
return this.makeRequest('POST', '/search', searchData);
|
||||
}
|
||||
|
||||
// Bootstrap API
|
||||
async bootstrap(bootstrapData) {
|
||||
return this.makeRequest('POST', '/auth/bootstrap', bootstrapData);
|
||||
}
|
||||
|
||||
// Health check
|
||||
async healthCheck() {
|
||||
this.updateConfig();
|
||||
const response = await fetch(`${this.baseUrl}/health`);
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
// Get image URL for display
|
||||
getImageUrl(imageId) {
|
||||
this.updateConfig();
|
||||
return `${this.baseUrl}/api/v1/images/${imageId}/file`;
|
||||
}
|
||||
|
||||
// Get image thumbnail URL
|
||||
getThumbnailUrl(imageId, size = 'medium') {
|
||||
this.updateConfig();
|
||||
return `${this.baseUrl}/api/v1/images/${imageId}/thumbnail?size=${size}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Global API client instance
|
||||
const apiClient = new ApiClient();
|
||||
|
||||
// Helper function to handle API errors consistently
|
||||
function handleApiError(error, context = '') {
|
||||
console.error(`API Error ${context}:`, error);
|
||||
|
||||
let message = error.message || 'An unknown error occurred';
|
||||
|
||||
// Handle common error types
|
||||
if (message.includes('Failed to fetch') || message.includes('NetworkError')) {
|
||||
message = 'Unable to connect to the server. Please check your connection and API configuration.';
|
||||
} else if (message.includes('401')) {
|
||||
message = 'Authentication failed. Please check your API key.';
|
||||
} else if (message.includes('403')) {
|
||||
message = 'Access denied. You don\'t have permission to perform this action.';
|
||||
} else if (message.includes('404')) {
|
||||
message = 'The requested resource was not found.';
|
||||
} else if (message.includes('500')) {
|
||||
message = 'Server error. Please try again later.';
|
||||
}
|
||||
|
||||
showAlert(message, 'danger');
|
||||
return message;
|
||||
}
|
||||
354
client/js/apikeys.js
Normal file
354
client/js/apikeys.js
Normal file
@ -0,0 +1,354 @@
|
||||
// API Key management functionality
|
||||
|
||||
// Load API keys
|
||||
async function loadApiKeys() {
|
||||
if (!config.isConfigured()) {
|
||||
showAlert('Please configure your API settings first.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.getElementById('apiKeysContainer');
|
||||
container.innerHTML = '<div class="text-center"><div class="loading-spinner"></div> Loading API keys...</div>';
|
||||
|
||||
try {
|
||||
const apiKeys = await apiClient.getApiKeys();
|
||||
displayApiKeys(apiKeys);
|
||||
} catch (error) {
|
||||
handleApiError(error, 'loading API keys');
|
||||
container.innerHTML = '<div class="alert alert-danger">Failed to load API keys</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Display API keys
|
||||
function displayApiKeys(apiKeys) {
|
||||
const container = document.getElementById('apiKeysContainer');
|
||||
|
||||
if (!apiKeys || apiKeys.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-key fa-3x text-muted mb-3"></i>
|
||||
<h4>No API keys found</h4>
|
||||
<p class="text-muted">Create your first API key to get started!</p>
|
||||
<button class="btn btn-primary" onclick="showCreateApiKeyModal()">
|
||||
<i class="fas fa-plus me-1"></i>Create API Key
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKeysHtml = `
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Key</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Last Used</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${apiKeys.map(key => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-key me-2 text-primary"></i>
|
||||
${escapeHtml(key.name)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<code class="me-2" id="key-${key.id}">
|
||||
${key.key ? key.key.substring(0, 8) + '...' : 'Hidden'}
|
||||
</code>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
onclick="copyApiKey('${key.key || ''}')"
|
||||
title="Copy to clipboard">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge ${key.is_active ? 'bg-success' : 'bg-danger'}">
|
||||
${key.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted small">${formatDate(key.created_at)}</td>
|
||||
<td class="text-muted small">
|
||||
${key.last_used_at ? formatDate(key.last_used_at) : 'Never'}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="viewApiKey('${key.id}')">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteApiKey('${key.id}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = apiKeysHtml;
|
||||
}
|
||||
|
||||
// Show create API key modal
|
||||
function showCreateApiKeyModal() {
|
||||
const modalBody = `
|
||||
<form id="createApiKeyForm">
|
||||
<div class="mb-3">
|
||||
<label for="apiKeyName" class="form-label">API Key Name *</label>
|
||||
<input type="text" class="form-control" id="apiKeyName" required
|
||||
placeholder="e.g., Mobile App, Integration Service">
|
||||
<div class="form-text">Choose a descriptive name to identify this API key</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="apiKeyDescription" class="form-label">Description</label>
|
||||
<textarea class="form-control" id="apiKeyDescription" rows="3"
|
||||
placeholder="Describe what this API key will be used for..."></textarea>
|
||||
</div>
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
<strong>Important:</strong> The API key will only be shown once after creation.
|
||||
Make sure to copy and store it securely.
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const modalFooter = `
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="createApiKey()">
|
||||
<i class="fas fa-plus me-1"></i>Create API Key
|
||||
</button>
|
||||
`;
|
||||
|
||||
const modal = createModal('createApiKeyModal', 'Create New API Key', modalBody, modalFooter);
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('createApiKeyForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
createApiKey();
|
||||
});
|
||||
|
||||
modal.show();
|
||||
|
||||
// Focus on name field
|
||||
setTimeout(() => document.getElementById('apiKeyName').focus(), 100);
|
||||
}
|
||||
|
||||
// Create API key
|
||||
async function createApiKey() {
|
||||
const name = document.getElementById('apiKeyName').value.trim();
|
||||
const description = document.getElementById('apiKeyDescription').value.trim();
|
||||
|
||||
if (!name) {
|
||||
showAlert('API key name is required', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const createButton = document.querySelector('#createApiKeyModal .btn-primary');
|
||||
setLoadingState(createButton);
|
||||
|
||||
try {
|
||||
const result = await apiClient.createApiKey({
|
||||
name,
|
||||
description
|
||||
});
|
||||
|
||||
// Close the create modal
|
||||
bootstrap.Modal.getInstance(document.getElementById('createApiKeyModal')).hide();
|
||||
removeModal('createApiKeyModal');
|
||||
|
||||
// Show the new API key
|
||||
showNewApiKeyModal(result);
|
||||
|
||||
// Refresh the list
|
||||
loadApiKeys();
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'creating API key');
|
||||
} finally {
|
||||
setLoadingState(createButton, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Show new API key modal
|
||||
function showNewApiKeyModal(apiKeyData) {
|
||||
const modalBody = `
|
||||
<div class="alert alert-success">
|
||||
<i class="fas fa-check-circle me-2"></i>
|
||||
<strong>API Key Created Successfully!</strong>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label"><strong>API Key Name:</strong></label>
|
||||
<p>${escapeHtml(apiKeyData.name)}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label"><strong>API Key:</strong></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="newApiKeyValue"
|
||||
value="${apiKeyData.key}" readonly>
|
||||
<button class="btn btn-outline-secondary" onclick="copyApiKey('${apiKeyData.key}')">
|
||||
<i class="fas fa-copy"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>Important:</strong> This is the only time you'll see this API key.
|
||||
Make sure to copy and store it securely before closing this dialog.
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modalFooter = `
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="copyApiKey('${apiKeyData.key}')">
|
||||
<i class="fas fa-copy me-1"></i>Copy & Close
|
||||
</button>
|
||||
`;
|
||||
|
||||
const modal = createModal('newApiKeyModal', 'New API Key Created', modalBody, modalFooter);
|
||||
modal.show();
|
||||
|
||||
// Auto-select the API key text
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById('newApiKeyValue');
|
||||
input.select();
|
||||
input.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Copy API key to clipboard
|
||||
async function copyApiKey(key) {
|
||||
if (!key) {
|
||||
showAlert('No API key to copy', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await copyToClipboard(key);
|
||||
} catch (error) {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = key;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
showAlert('API key copied to clipboard!', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
// View API key details
|
||||
async function viewApiKey(keyId) {
|
||||
try {
|
||||
const apiKeys = await apiClient.getApiKeys();
|
||||
const apiKey = apiKeys.find(k => k.id === keyId);
|
||||
|
||||
if (!apiKey) {
|
||||
showAlert('API key not found', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const modalBody = `
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h5><i class="fas fa-key me-2"></i>${escapeHtml(apiKey.name)}</h5>
|
||||
<p class="text-muted">${escapeHtml(apiKey.description || 'No description')}</p>
|
||||
<hr>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Key Information</h6>
|
||||
<p><strong>Created:</strong> ${formatDate(apiKey.created_at)}</p>
|
||||
<p><strong>Last Used:</strong> ${apiKey.last_used_at ? formatDate(apiKey.last_used_at) : 'Never'}</p>
|
||||
<p><strong>ID:</strong> <code>${apiKey.id}</code></p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Status & Security</h6>
|
||||
<p><strong>Status:</strong>
|
||||
<span class="badge ${apiKey.is_active ? 'bg-success' : 'bg-danger'}">
|
||||
${apiKey.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</p>
|
||||
<p><strong>Key Preview:</strong> <code>${apiKey.key ? apiKey.key.substring(0, 8) + '...' : 'Hidden'}</code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${apiKey.last_used_at ? `
|
||||
<div class="mt-3">
|
||||
<h6>Usage Statistics</h6>
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-chart-line me-2"></i>
|
||||
This API key was last used on ${formatDate(apiKey.last_used_at)}
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<div class="mt-3">
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
This API key has never been used
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modalFooter = `
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-danger" onclick="deleteApiKey('${keyId}')">
|
||||
<i class="fas fa-trash me-1"></i>Delete Key
|
||||
</button>
|
||||
`;
|
||||
|
||||
const modal = createModal('viewApiKeyModal', 'API Key Details', modalBody, modalFooter);
|
||||
modal.show();
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'loading API key details');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete API key
|
||||
function deleteApiKey(keyId) {
|
||||
confirmAction('Are you sure you want to delete this API key? This action cannot be undone and will immediately revoke access for any applications using this key.', async () => {
|
||||
try {
|
||||
await apiClient.deleteApiKey(keyId);
|
||||
showAlert('API key deleted successfully!', 'success');
|
||||
loadApiKeys();
|
||||
|
||||
// Close any open modals
|
||||
const modals = ['viewApiKeyModal', 'newApiKeyModal'];
|
||||
modals.forEach(modalId => {
|
||||
const modalElement = document.getElementById(modalId);
|
||||
if (modalElement) {
|
||||
bootstrap.Modal.getInstance(modalElement)?.hide();
|
||||
removeModal(modalId);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'deleting API key');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Generate API key usage report
|
||||
function generateUsageReport() {
|
||||
showAlert('Usage report functionality would be implemented here', 'info');
|
||||
}
|
||||
|
||||
// Bulk operations for API keys
|
||||
function bulkDeleteApiKeys() {
|
||||
showAlert('Bulk operations functionality would be implemented here', 'info');
|
||||
}
|
||||
362
client/js/app.js
Normal file
362
client/js/app.js
Normal file
@ -0,0 +1,362 @@
|
||||
// Main application initialization and global functionality
|
||||
|
||||
// Application state
|
||||
const app = {
|
||||
initialized: false,
|
||||
currentPage: 'home',
|
||||
version: '1.0.0'
|
||||
};
|
||||
|
||||
// Initialize the application
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('SeReact Frontend v' + app.version + ' - Initializing...');
|
||||
|
||||
// Initialize configuration
|
||||
initializeApp();
|
||||
|
||||
// Set up global event listeners
|
||||
setupGlobalEventListeners();
|
||||
|
||||
// Check initial configuration state
|
||||
checkInitialConfiguration();
|
||||
|
||||
app.initialized = true;
|
||||
console.log('SeReact Frontend - Initialization complete');
|
||||
});
|
||||
|
||||
// Initialize the application
|
||||
function initializeApp() {
|
||||
// Update navigation state based on configuration
|
||||
updateNavigationState();
|
||||
|
||||
// Initialize page routing
|
||||
initializeRouting();
|
||||
|
||||
// Set up periodic health checks
|
||||
setupHealthChecks();
|
||||
|
||||
// Initialize keyboard shortcuts
|
||||
setupKeyboardShortcuts();
|
||||
}
|
||||
|
||||
// Set up global event listeners
|
||||
function setupGlobalEventListeners() {
|
||||
// Handle online/offline status
|
||||
window.addEventListener('online', () => {
|
||||
showAlert('Connection restored', 'success');
|
||||
updateConnectionStatus(true);
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
showAlert('Connection lost - working offline', 'warning', true);
|
||||
updateConnectionStatus(false);
|
||||
});
|
||||
|
||||
// Handle visibility changes (tab switching)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && config.isConfigured()) {
|
||||
// Refresh current page data when tab becomes visible
|
||||
refreshCurrentPageData();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', debounce(() => {
|
||||
handleWindowResize();
|
||||
}, 250));
|
||||
|
||||
// Handle beforeunload for unsaved changes warning
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (hasUnsavedChanges()) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check initial configuration
|
||||
function checkInitialConfiguration() {
|
||||
if (!config.isConfigured()) {
|
||||
// Show welcome message for first-time users
|
||||
setTimeout(() => {
|
||||
showWelcomeMessage();
|
||||
}, 1000);
|
||||
} else {
|
||||
// Test connection on startup
|
||||
setTimeout(() => {
|
||||
testConnectionSilently();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Show welcome message for new users
|
||||
function showWelcomeMessage() {
|
||||
const modalBody = `
|
||||
<div class="text-center mb-4">
|
||||
<i class="fas fa-rocket fa-3x text-primary mb-3"></i>
|
||||
<h4>Welcome to SeReact!</h4>
|
||||
<p class="lead">AI-powered image management and semantic search platform</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-cog fa-2x text-primary mb-3"></i>
|
||||
<h6>Configure API</h6>
|
||||
<p class="small">Set up your API connection to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-upload fa-2x text-success mb-3"></i>
|
||||
<h6>Upload Images</h6>
|
||||
<p class="small">Start building your image collection</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-center">
|
||||
<p class="text-muted">Ready to begin? Let's configure your API connection first.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modalFooter = `
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Maybe Later</button>
|
||||
<button type="button" class="btn btn-primary" onclick="showPage('config')" data-bs-dismiss="modal">
|
||||
<i class="fas fa-cog me-1"></i>Configure Now
|
||||
</button>
|
||||
`;
|
||||
|
||||
const modal = createModal('welcomeModal', 'Welcome to SeReact', modalBody, modalFooter);
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Test connection silently (without showing alerts)
|
||||
async function testConnectionSilently() {
|
||||
try {
|
||||
const isHealthy = await apiClient.healthCheck();
|
||||
updateConnectionStatus(isHealthy);
|
||||
} catch (error) {
|
||||
updateConnectionStatus(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Update connection status indicator
|
||||
function updateConnectionStatus(isOnline) {
|
||||
// Add a connection status indicator to the navbar
|
||||
let statusIndicator = document.getElementById('connectionStatus');
|
||||
|
||||
if (!statusIndicator) {
|
||||
statusIndicator = document.createElement('div');
|
||||
statusIndicator.id = 'connectionStatus';
|
||||
statusIndicator.className = 'nav-item';
|
||||
|
||||
const navbar = document.querySelector('.navbar-nav');
|
||||
if (navbar) {
|
||||
navbar.appendChild(statusIndicator);
|
||||
}
|
||||
}
|
||||
|
||||
statusIndicator.innerHTML = `
|
||||
<span class="nav-link">
|
||||
<i class="fas fa-circle ${isOnline ? 'status-online' : 'status-offline'}"
|
||||
title="${isOnline ? 'Connected' : 'Disconnected'}"></i>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// Initialize routing
|
||||
function initializeRouting() {
|
||||
// Handle hash changes for deep linking
|
||||
window.addEventListener('hashchange', handleRouteChange);
|
||||
|
||||
// Handle initial route
|
||||
handleRouteChange();
|
||||
}
|
||||
|
||||
// Handle route changes
|
||||
function handleRouteChange() {
|
||||
const hash = window.location.hash.substring(1);
|
||||
const route = hash || 'home';
|
||||
|
||||
// Validate route
|
||||
const validRoutes = ['home', 'config', 'images', 'search', 'teams', 'users', 'api-keys'];
|
||||
|
||||
if (validRoutes.includes(route)) {
|
||||
showPage(route);
|
||||
} else {
|
||||
// Invalid route, redirect to home
|
||||
window.location.hash = 'home';
|
||||
}
|
||||
}
|
||||
|
||||
// Set up periodic health checks
|
||||
function setupHealthChecks() {
|
||||
if (!config.isConfigured()) return;
|
||||
|
||||
// Check health every 5 minutes
|
||||
setInterval(async () => {
|
||||
if (config.isConfigured() && navigator.onLine) {
|
||||
try {
|
||||
const isHealthy = await apiClient.healthCheck();
|
||||
updateConnectionStatus(isHealthy);
|
||||
} catch (error) {
|
||||
updateConnectionStatus(false);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
// Set up keyboard shortcuts
|
||||
function setupKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Only handle shortcuts when not in input fields
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + shortcuts
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch (e.key) {
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
showPage('search');
|
||||
setTimeout(() => {
|
||||
const searchInput = document.getElementById('searchQuery');
|
||||
if (searchInput) searchInput.focus();
|
||||
}, 100);
|
||||
break;
|
||||
case 'u':
|
||||
e.preventDefault();
|
||||
if (app.currentPage === 'images') {
|
||||
showUploadModal();
|
||||
} else {
|
||||
showPage('images');
|
||||
setTimeout(() => showUploadModal(), 100);
|
||||
}
|
||||
break;
|
||||
case ',':
|
||||
e.preventDefault();
|
||||
showPage('config');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Number shortcuts for navigation
|
||||
if (e.key >= '1' && e.key <= '6' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
const pages = ['home', 'images', 'search', 'teams', 'users', 'api-keys'];
|
||||
const pageIndex = parseInt(e.key) - 1;
|
||||
if (pages[pageIndex]) {
|
||||
e.preventDefault();
|
||||
showPage(pages[pageIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
// Escape key to close modals
|
||||
if (e.key === 'Escape') {
|
||||
const openModals = document.querySelectorAll('.modal.show');
|
||||
openModals.forEach(modal => {
|
||||
const bsModal = bootstrap.Modal.getInstance(modal);
|
||||
if (bsModal) bsModal.hide();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh current page data
|
||||
function refreshCurrentPageData() {
|
||||
switch (app.currentPage) {
|
||||
case 'images':
|
||||
loadImages(currentPage);
|
||||
break;
|
||||
case 'teams':
|
||||
loadTeams();
|
||||
break;
|
||||
case 'users':
|
||||
loadUsers();
|
||||
break;
|
||||
case 'api-keys':
|
||||
loadApiKeys();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle window resize
|
||||
function handleWindowResize() {
|
||||
// Reinitialize tooltips and popovers after resize
|
||||
initializeTooltips();
|
||||
initializePopovers();
|
||||
|
||||
// Adjust image grid if on images page
|
||||
if (app.currentPage === 'images') {
|
||||
// Force reflow of image grid
|
||||
const imageGrid = document.querySelector('.image-grid');
|
||||
if (imageGrid) {
|
||||
imageGrid.style.display = 'none';
|
||||
imageGrid.offsetHeight; // Trigger reflow
|
||||
imageGrid.style.display = 'grid';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unsaved changes
|
||||
function hasUnsavedChanges() {
|
||||
// Check if any forms have been modified
|
||||
const forms = document.querySelectorAll('form');
|
||||
for (const form of forms) {
|
||||
if (form.dataset.modified === 'true') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mark form as modified
|
||||
function markFormAsModified(formElement) {
|
||||
formElement.dataset.modified = 'true';
|
||||
}
|
||||
|
||||
// Mark form as saved
|
||||
function markFormAsSaved(formElement) {
|
||||
formElement.dataset.modified = 'false';
|
||||
}
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', (e) => {
|
||||
console.error('Global error:', e.error);
|
||||
|
||||
// Don't show alerts for network errors or script loading errors
|
||||
if (e.error && !e.error.message.includes('Loading')) {
|
||||
showAlert('An unexpected error occurred. Please refresh the page if problems persist.', 'danger');
|
||||
}
|
||||
});
|
||||
|
||||
// Global unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
console.error('Unhandled promise rejection:', e.reason);
|
||||
|
||||
// Don't show alerts for network-related rejections
|
||||
if (e.reason && !e.reason.message?.includes('fetch')) {
|
||||
showAlert('An unexpected error occurred. Please try again.', 'danger');
|
||||
}
|
||||
|
||||
// Prevent the default browser behavior
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// Export app object for debugging
|
||||
window.SeReactApp = app;
|
||||
|
||||
// Add helpful console messages
|
||||
console.log('%cSeReact Frontend', 'color: #0d6efd; font-size: 24px; font-weight: bold;');
|
||||
console.log('%cVersion: ' + app.version, 'color: #6c757d; font-size: 14px;');
|
||||
console.log('%cKeyboard Shortcuts:', 'color: #198754; font-size: 16px; font-weight: bold;');
|
||||
console.log('%c Ctrl+K: Search', 'color: #6c757d;');
|
||||
console.log('%c Ctrl+U: Upload Image', 'color: #6c757d;');
|
||||
console.log('%c Ctrl+,: Settings', 'color: #6c757d;');
|
||||
console.log('%c 1-6: Navigate to pages', 'color: #6c757d;');
|
||||
console.log('%c Esc: Close modals', 'color: #6c757d;');
|
||||
166
client/js/config.js
Normal file
166
client/js/config.js
Normal file
@ -0,0 +1,166 @@
|
||||
// Configuration management
|
||||
class Config {
|
||||
constructor() {
|
||||
this.apiBaseUrl = localStorage.getItem('apiBaseUrl') || 'http://localhost:8000';
|
||||
this.apiKey = localStorage.getItem('apiKey') || '';
|
||||
}
|
||||
|
||||
setApiBaseUrl(url) {
|
||||
this.apiBaseUrl = url.replace(/\/$/, ''); // Remove trailing slash
|
||||
localStorage.setItem('apiBaseUrl', this.apiBaseUrl);
|
||||
}
|
||||
|
||||
setApiKey(key) {
|
||||
this.apiKey = key;
|
||||
localStorage.setItem('apiKey', this.apiKey);
|
||||
}
|
||||
|
||||
getApiBaseUrl() {
|
||||
return this.apiBaseUrl;
|
||||
}
|
||||
|
||||
getApiKey() {
|
||||
return this.apiKey;
|
||||
}
|
||||
|
||||
isConfigured() {
|
||||
return this.apiBaseUrl && this.apiKey;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.apiBaseUrl = '';
|
||||
this.apiKey = '';
|
||||
localStorage.removeItem('apiBaseUrl');
|
||||
localStorage.removeItem('apiKey');
|
||||
}
|
||||
}
|
||||
|
||||
// Global config instance
|
||||
const config = new Config();
|
||||
|
||||
// Initialize configuration form
|
||||
function initializeConfigForm() {
|
||||
const form = document.getElementById('configForm');
|
||||
const apiBaseUrlInput = document.getElementById('apiBaseUrl');
|
||||
const apiKeyInput = document.getElementById('apiKey');
|
||||
|
||||
// Load current values
|
||||
apiBaseUrlInput.value = config.getApiBaseUrl();
|
||||
apiKeyInput.value = config.getApiKey();
|
||||
|
||||
// Handle form submission
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const newBaseUrl = apiBaseUrlInput.value.trim();
|
||||
const newApiKey = apiKeyInput.value.trim();
|
||||
|
||||
if (!newBaseUrl) {
|
||||
showAlert('API Base URL is required', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
config.setApiBaseUrl(newBaseUrl);
|
||||
if (newApiKey) {
|
||||
config.setApiKey(newApiKey);
|
||||
}
|
||||
|
||||
showAlert('Configuration saved successfully!', 'success');
|
||||
|
||||
// Update navigation state
|
||||
updateNavigationState();
|
||||
});
|
||||
}
|
||||
|
||||
// Test API connection
|
||||
async function testConnection() {
|
||||
const apiBaseUrl = document.getElementById('apiBaseUrl').value.trim();
|
||||
const apiKey = document.getElementById('apiKey').value.trim();
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
showAlert('API Base URL is required', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
showAlert('API Key is required', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const testButton = document.querySelector('button[onclick="testConnection()"]');
|
||||
const originalText = testButton.innerHTML;
|
||||
testButton.innerHTML = '<span class="loading-spinner"></span> Testing...';
|
||||
testButton.disabled = true;
|
||||
|
||||
try {
|
||||
// Test health endpoint first
|
||||
const healthResponse = await fetch(`${apiBaseUrl}/health`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!healthResponse.ok) {
|
||||
throw new Error(`Health check failed with status ${healthResponse.status}`);
|
||||
}
|
||||
|
||||
// Test API authentication
|
||||
const authResponse = await fetch(`${apiBaseUrl}/api/v1/teams`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': apiKey
|
||||
}
|
||||
});
|
||||
|
||||
if (authResponse.status === 401) {
|
||||
throw new Error('Authentication failed - Invalid API key');
|
||||
} else if (authResponse.status === 403) {
|
||||
throw new Error('Access forbidden - API key lacks permissions');
|
||||
} else if (!authResponse.ok) {
|
||||
throw new Error(`API request failed with status ${authResponse.status}`);
|
||||
}
|
||||
|
||||
showAlert('API connection successful! Backend is up and running.', 'success');
|
||||
|
||||
// Save the working configuration
|
||||
config.setApiBaseUrl(apiBaseUrl);
|
||||
config.setApiKey(apiKey);
|
||||
|
||||
// Update form values
|
||||
document.getElementById('apiBaseUrl').value = apiBaseUrl;
|
||||
document.getElementById('apiKey').value = apiKey;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Connection test failed:', error);
|
||||
showAlert(`Connection test failed: ${error.message}`, 'danger');
|
||||
} finally {
|
||||
testButton.innerHTML = originalText;
|
||||
testButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update navigation state based on configuration
|
||||
function updateNavigationState() {
|
||||
const navLinks = document.querySelectorAll('.navbar-nav .nav-link');
|
||||
const isConfigured = config.isConfigured();
|
||||
|
||||
navLinks.forEach(link => {
|
||||
const onclick = link.getAttribute('onclick');
|
||||
if (onclick && onclick.includes('showPage') && !onclick.includes('config') && !onclick.includes('home')) {
|
||||
if (isConfigured) {
|
||||
link.classList.remove('disabled');
|
||||
link.style.opacity = '1';
|
||||
} else {
|
||||
link.classList.add('disabled');
|
||||
link.style.opacity = '0.5';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Show configuration warning if not configured
|
||||
if (!isConfigured && window.location.hash !== '#config') {
|
||||
showAlert('Please configure your API settings first.', 'warning', true);
|
||||
}
|
||||
}
|
||||
370
client/js/images.js
Normal file
370
client/js/images.js
Normal file
@ -0,0 +1,370 @@
|
||||
// Image management functionality
|
||||
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
|
||||
// Load images with pagination
|
||||
async function loadImages(page = 1, tags = null) {
|
||||
if (!config.isConfigured()) {
|
||||
showAlert('Please configure your API settings first.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.getElementById('imagesContainer');
|
||||
container.innerHTML = '<div class="text-center"><div class="loading-spinner"></div> Loading images...</div>';
|
||||
|
||||
try {
|
||||
const response = await apiClient.getImages(page, 20, tags);
|
||||
currentPage = page;
|
||||
totalPages = Math.ceil(response.total / response.limit);
|
||||
|
||||
displayImages(response.images);
|
||||
displayPagination(response);
|
||||
} catch (error) {
|
||||
handleApiError(error, 'loading images');
|
||||
container.innerHTML = '<div class="alert alert-danger">Failed to load images</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Display images in grid
|
||||
function displayImages(images) {
|
||||
const container = document.getElementById('imagesContainer');
|
||||
|
||||
if (!images || images.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-images fa-3x text-muted mb-3"></i>
|
||||
<h4>No images found</h4>
|
||||
<p class="text-muted">Upload your first image to get started!</p>
|
||||
<button class="btn btn-primary" onclick="showUploadModal()">
|
||||
<i class="fas fa-plus me-1"></i>Upload Image
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const imagesHtml = images.map(image => `
|
||||
<div class="image-card card">
|
||||
<img src="${apiClient.getThumbnailUrl(image.id)}"
|
||||
alt="${escapeHtml(image.description || 'Image')}"
|
||||
onclick="viewImage('${image.id}')"
|
||||
style="cursor: pointer;">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">${escapeHtml(truncateText(image.description || 'Untitled', 50))}</h6>
|
||||
<p class="card-text small text-muted">
|
||||
<i class="fas fa-calendar me-1"></i>${formatDate(image.created_at)}
|
||||
</p>
|
||||
${image.tags && image.tags.length > 0 ? `
|
||||
<div class="mb-2">
|
||||
${image.tags.map(tag => `<span class="badge bg-secondary me-1">${escapeHtml(tag)}</span>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="btn-group w-100" role="group">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="viewImage('${image.id}')">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="editImage('${image.id}')">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteImage('${image.id}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.innerHTML = `<div class="image-grid">${imagesHtml}</div>`;
|
||||
}
|
||||
|
||||
// Display pagination
|
||||
function displayPagination(response) {
|
||||
const container = document.getElementById('imagesContainer');
|
||||
|
||||
if (totalPages <= 1) return;
|
||||
|
||||
const paginationHtml = `
|
||||
<nav class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="loadImages(${currentPage - 1})">Previous</a>
|
||||
</li>
|
||||
${Array.from({length: Math.min(totalPages, 5)}, (_, i) => {
|
||||
const page = i + 1;
|
||||
return `
|
||||
<li class="page-item ${page === currentPage ? 'active' : ''}">
|
||||
<a class="page-link" href="#" onclick="loadImages(${page})">${page}</a>
|
||||
</li>
|
||||
`;
|
||||
}).join('')}
|
||||
<li class="page-item ${currentPage === totalPages ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="loadImages(${currentPage + 1})">Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
`;
|
||||
|
||||
container.insertAdjacentHTML('beforeend', paginationHtml);
|
||||
}
|
||||
|
||||
// Show upload modal
|
||||
function showUploadModal() {
|
||||
const modalBody = `
|
||||
<form id="uploadForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Select Image</label>
|
||||
<div class="upload-area" id="uploadArea">
|
||||
<i class="fas fa-cloud-upload-alt fa-3x text-primary mb-3"></i>
|
||||
<h5>Drag & drop an image here</h5>
|
||||
<p class="text-muted">or click to select a file</p>
|
||||
<input type="file" id="imageFile" accept="image/*" style="display: none;">
|
||||
</div>
|
||||
<div id="imagePreview" class="mt-3" style="display: none;">
|
||||
<img id="previewImg" class="img-fluid rounded" style="max-height: 200px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="imageDescription" class="form-label">Description</label>
|
||||
<textarea class="form-control" id="imageDescription" rows="3"
|
||||
placeholder="Describe this image..."></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="imageTags" class="form-label">Tags</label>
|
||||
<input type="text" class="form-control" id="imageTags"
|
||||
placeholder="Enter tags separated by commas">
|
||||
<div class="form-text">e.g., nature, landscape, sunset</div>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const modalFooter = `
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="uploadImage()">
|
||||
<i class="fas fa-upload me-1"></i>Upload
|
||||
</button>
|
||||
`;
|
||||
|
||||
const modal = createModal('uploadModal', 'Upload Image', modalBody, modalFooter);
|
||||
|
||||
// Setup file input and drag & drop
|
||||
const uploadArea = document.getElementById('uploadArea');
|
||||
const fileInput = document.getElementById('imageFile');
|
||||
|
||||
uploadArea.addEventListener('click', () => fileInput.click());
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
handleFileSelection(e.target.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
setupFileDropZone(uploadArea, (files) => {
|
||||
if (files.length > 0) {
|
||||
handleFileSelection(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Handle file selection
|
||||
async function handleFileSelection(file) {
|
||||
try {
|
||||
validateFile(file);
|
||||
|
||||
const preview = await createImagePreview(file);
|
||||
const previewContainer = document.getElementById('imagePreview');
|
||||
const previewImg = document.getElementById('previewImg');
|
||||
|
||||
previewImg.src = preview;
|
||||
previewContainer.style.display = 'block';
|
||||
|
||||
// Store file for upload
|
||||
document.getElementById('uploadForm').selectedFile = file;
|
||||
|
||||
} catch (error) {
|
||||
showAlert(error.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Upload image
|
||||
async function uploadImage() {
|
||||
const form = document.getElementById('uploadForm');
|
||||
const file = form.selectedFile;
|
||||
|
||||
if (!file) {
|
||||
showAlert('Please select an image file', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const description = document.getElementById('imageDescription').value.trim();
|
||||
const tagsInput = document.getElementById('imageTags').value.trim();
|
||||
const tags = tagsInput ? tagsInput.split(',').map(tag => tag.trim()).filter(tag => tag) : [];
|
||||
|
||||
const uploadButton = document.querySelector('#uploadModal .btn-primary');
|
||||
setLoadingState(uploadButton);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('description', description);
|
||||
if (tags.length > 0) {
|
||||
formData.append('tags', JSON.stringify(tags));
|
||||
}
|
||||
|
||||
await apiClient.uploadImage(formData);
|
||||
|
||||
showAlert('Image uploaded successfully!', 'success');
|
||||
|
||||
// Close modal and refresh images
|
||||
bootstrap.Modal.getInstance(document.getElementById('uploadModal')).hide();
|
||||
removeModal('uploadModal');
|
||||
loadImages(currentPage);
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'uploading image');
|
||||
} finally {
|
||||
setLoadingState(uploadButton, false);
|
||||
}
|
||||
}
|
||||
|
||||
// View image details
|
||||
async function viewImage(imageId) {
|
||||
try {
|
||||
const image = await apiClient.getImage(imageId);
|
||||
|
||||
const modalBody = `
|
||||
<div class="text-center mb-3">
|
||||
<img src="${apiClient.getImageUrl(imageId)}" class="img-fluid rounded"
|
||||
style="max-height: 400px;">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Description</h6>
|
||||
<p>${escapeHtml(image.description || 'No description')}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Details</h6>
|
||||
<p><strong>Created:</strong> ${formatDate(image.created_at)}</p>
|
||||
<p><strong>Size:</strong> ${formatFileSize(image.file_size)}</p>
|
||||
<p><strong>Dimensions:</strong> ${image.width} × ${image.height}</p>
|
||||
</div>
|
||||
</div>
|
||||
${image.tags && image.tags.length > 0 ? `
|
||||
<div class="mt-3">
|
||||
<h6>Tags</h6>
|
||||
${image.tags.map(tag => `<span class="badge bg-secondary me-1">${escapeHtml(tag)}</span>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
const modalFooter = `
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" onclick="editImage('${imageId}')">
|
||||
<i class="fas fa-edit me-1"></i>Edit
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" onclick="deleteImage('${imageId}')">
|
||||
<i class="fas fa-trash me-1"></i>Delete
|
||||
</button>
|
||||
`;
|
||||
|
||||
const modal = createModal('viewImageModal', 'Image Details', modalBody, modalFooter);
|
||||
modal.show();
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'loading image details');
|
||||
}
|
||||
}
|
||||
|
||||
// Edit image
|
||||
async function editImage(imageId) {
|
||||
try {
|
||||
const image = await apiClient.getImage(imageId);
|
||||
|
||||
const modalBody = `
|
||||
<form id="editImageForm">
|
||||
<div class="mb-3 text-center">
|
||||
<img src="${apiClient.getThumbnailUrl(imageId)}" class="img-fluid rounded"
|
||||
style="max-height: 200px;">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editDescription" class="form-label">Description</label>
|
||||
<textarea class="form-control" id="editDescription" rows="3">${escapeHtml(image.description || '')}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editTags" class="form-label">Tags</label>
|
||||
<input type="text" class="form-control" id="editTags"
|
||||
value="${image.tags ? image.tags.join(', ') : ''}">
|
||||
<div class="form-text">Enter tags separated by commas</div>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const modalFooter = `
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveImageChanges('${imageId}')">
|
||||
<i class="fas fa-save me-1"></i>Save Changes
|
||||
</button>
|
||||
`;
|
||||
|
||||
const modal = createModal('editImageModal', 'Edit Image', modalBody, modalFooter);
|
||||
modal.show();
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'loading image for editing');
|
||||
}
|
||||
}
|
||||
|
||||
// Save image changes
|
||||
async function saveImageChanges(imageId) {
|
||||
const description = document.getElementById('editDescription').value.trim();
|
||||
const tagsInput = document.getElementById('editTags').value.trim();
|
||||
const tags = tagsInput ? tagsInput.split(',').map(tag => tag.trim()).filter(tag => tag) : [];
|
||||
|
||||
const saveButton = document.querySelector('#editImageModal .btn-primary');
|
||||
setLoadingState(saveButton);
|
||||
|
||||
try {
|
||||
await apiClient.updateImage(imageId, {
|
||||
description,
|
||||
tags
|
||||
});
|
||||
|
||||
showAlert('Image updated successfully!', 'success');
|
||||
|
||||
// Close modal and refresh images
|
||||
bootstrap.Modal.getInstance(document.getElementById('editImageModal')).hide();
|
||||
removeModal('editImageModal');
|
||||
loadImages(currentPage);
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'updating image');
|
||||
} finally {
|
||||
setLoadingState(saveButton, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete image
|
||||
function deleteImage(imageId) {
|
||||
confirmAction('Are you sure you want to delete this image? This action cannot be undone.', async () => {
|
||||
try {
|
||||
await apiClient.deleteImage(imageId);
|
||||
showAlert('Image deleted successfully!', 'success');
|
||||
loadImages(currentPage);
|
||||
|
||||
// Close any open modals
|
||||
const modals = ['viewImageModal', 'editImageModal'];
|
||||
modals.forEach(modalId => {
|
||||
const modalElement = document.getElementById(modalId);
|
||||
if (modalElement) {
|
||||
bootstrap.Modal.getInstance(modalElement)?.hide();
|
||||
removeModal(modalId);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'deleting image');
|
||||
}
|
||||
});
|
||||
}
|
||||
297
client/js/search.js
Normal file
297
client/js/search.js
Normal file
@ -0,0 +1,297 @@
|
||||
// AI-powered image search functionality
|
||||
|
||||
// Initialize search form
|
||||
function initializeSearchForm() {
|
||||
const form = document.getElementById('searchForm');
|
||||
const thresholdSlider = document.getElementById('similarityThreshold');
|
||||
const thresholdValue = document.getElementById('thresholdValue');
|
||||
|
||||
// Update threshold display
|
||||
thresholdSlider.addEventListener('input', (e) => {
|
||||
thresholdValue.textContent = e.target.value;
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
performSearch();
|
||||
});
|
||||
}
|
||||
|
||||
// Perform search
|
||||
async function performSearch() {
|
||||
if (!config.isConfigured()) {
|
||||
showAlert('Please configure your API settings first.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const query = document.getElementById('searchQuery').value.trim();
|
||||
const threshold = parseFloat(document.getElementById('similarityThreshold').value);
|
||||
const maxResults = parseInt(document.getElementById('maxResults').value);
|
||||
|
||||
if (!query) {
|
||||
showAlert('Please enter a search query', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const resultsContainer = document.getElementById('searchResults');
|
||||
resultsContainer.innerHTML = `
|
||||
<div class="text-center py-4">
|
||||
<div class="loading-spinner"></div>
|
||||
<p class="mt-2">Searching for "${escapeHtml(query)}"...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const results = await apiClient.searchImages(query, threshold, maxResults);
|
||||
displaySearchResults(results, query);
|
||||
} catch (error) {
|
||||
handleApiError(error, 'searching images');
|
||||
resultsContainer.innerHTML = `
|
||||
<div class="alert alert-danger">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
Search failed. Please try again.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Display search results
|
||||
function displaySearchResults(results, query) {
|
||||
const container = document.getElementById('searchResults');
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-search fa-3x text-muted mb-3"></i>
|
||||
<h4>No results found</h4>
|
||||
<p class="text-muted">Try adjusting your search query or similarity threshold.</p>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-outline-primary" onclick="document.getElementById('searchQuery').focus()">
|
||||
<i class="fas fa-edit me-1"></i>Refine Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const resultsHtml = `
|
||||
<div class="mb-4">
|
||||
<h4>Search Results</h4>
|
||||
<p class="text-muted">Found ${results.length} images matching "${escapeHtml(query)}"</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
${results.map(result => `
|
||||
<div class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="card search-result h-100">
|
||||
<div class="position-relative">
|
||||
<img src="${apiClient.getThumbnailUrl(result.image.id)}"
|
||||
class="card-img-top"
|
||||
alt="${escapeHtml(result.image.description || 'Image')}"
|
||||
style="height: 200px; object-fit: cover; cursor: pointer;"
|
||||
onclick="viewImage('${result.image.id}')">
|
||||
<div class="position-absolute top-0 end-0 m-2">
|
||||
<span class="badge bg-primary similarity-score">
|
||||
${Math.round(result.similarity * 100)}% match
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">${escapeHtml(truncateText(result.image.description || 'Untitled', 60))}</h6>
|
||||
<p class="card-text small text-muted">
|
||||
<i class="fas fa-calendar me-1"></i>${formatDate(result.image.created_at)}
|
||||
</p>
|
||||
${result.image.tags && result.image.tags.length > 0 ? `
|
||||
<div class="mb-2">
|
||||
${result.image.tags.slice(0, 3).map(tag =>
|
||||
`<span class="badge bg-secondary me-1">${escapeHtml(tag)}</span>`
|
||||
).join('')}
|
||||
${result.image.tags.length > 3 ?
|
||||
`<span class="badge bg-light text-dark">+${result.image.tags.length - 3}</span>` : ''
|
||||
}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="viewImage('${result.image.id}')">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="editImage('${result.image.id}')">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
Similarity: ${(result.similarity * 100).toFixed(1)}%
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = resultsHtml;
|
||||
|
||||
// Add search refinement options
|
||||
addSearchRefinementOptions(query, results);
|
||||
}
|
||||
|
||||
// Add search refinement options
|
||||
function addSearchRefinementOptions(query, results) {
|
||||
const container = document.getElementById('searchResults');
|
||||
|
||||
// Extract common tags from results
|
||||
const allTags = results.flatMap(result => result.image.tags || []);
|
||||
const tagCounts = {};
|
||||
allTags.forEach(tag => {
|
||||
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
||||
});
|
||||
|
||||
const popularTags = Object.entries(tagCounts)
|
||||
.sort(([,a], [,b]) => b - a)
|
||||
.slice(0, 8)
|
||||
.map(([tag]) => tag);
|
||||
|
||||
if (popularTags.length > 0) {
|
||||
const refinementHtml = `
|
||||
<div class="mt-4 p-3 bg-light rounded">
|
||||
<h6><i class="fas fa-filter me-2"></i>Refine your search</h6>
|
||||
<p class="small text-muted mb-2">Popular tags in these results:</p>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
${popularTags.map(tag => `
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
onclick="refineSearchWithTag('${escapeHtml(tag)}')">
|
||||
${escapeHtml(tag)}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.insertAdjacentHTML('beforeend', refinementHtml);
|
||||
}
|
||||
|
||||
// Add export/share options
|
||||
const actionsHtml = `
|
||||
<div class="mt-4 text-center">
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-outline-primary" onclick="exportSearchResults('${escapeHtml(query)}')">
|
||||
<i class="fas fa-download me-1"></i>Export Results
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="shareSearchResults('${escapeHtml(query)}')">
|
||||
<i class="fas fa-share me-1"></i>Share Search
|
||||
</button>
|
||||
<button class="btn btn-outline-info" onclick="saveSearch('${escapeHtml(query)}')">
|
||||
<i class="fas fa-bookmark me-1"></i>Save Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.insertAdjacentHTML('beforeend', actionsHtml);
|
||||
}
|
||||
|
||||
// Refine search with tag
|
||||
function refineSearchWithTag(tag) {
|
||||
const currentQuery = document.getElementById('searchQuery').value.trim();
|
||||
const newQuery = currentQuery ? `${currentQuery} ${tag}` : tag;
|
||||
|
||||
document.getElementById('searchQuery').value = newQuery;
|
||||
performSearch();
|
||||
}
|
||||
|
||||
// Export search results
|
||||
function exportSearchResults(query) {
|
||||
// This would typically generate a CSV or JSON export
|
||||
showAlert('Export functionality would be implemented here', 'info');
|
||||
}
|
||||
|
||||
// Share search results
|
||||
async function shareSearchResults(query) {
|
||||
const url = `${window.location.origin}${window.location.pathname}#search`;
|
||||
const text = `Check out these AI search results for "${query}"`;
|
||||
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: 'SeReact Search Results',
|
||||
text: text,
|
||||
url: url
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('Error sharing:', error);
|
||||
copyToClipboard(url);
|
||||
}
|
||||
} else {
|
||||
copyToClipboard(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Save search
|
||||
function saveSearch(query) {
|
||||
const savedSearches = JSON.parse(localStorage.getItem('savedSearches') || '[]');
|
||||
|
||||
if (!savedSearches.includes(query)) {
|
||||
savedSearches.push(query);
|
||||
localStorage.setItem('savedSearches', JSON.stringify(savedSearches));
|
||||
showAlert('Search saved successfully!', 'success');
|
||||
updateSavedSearches();
|
||||
} else {
|
||||
showAlert('This search is already saved', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// Update saved searches display
|
||||
function updateSavedSearches() {
|
||||
const savedSearches = JSON.parse(localStorage.getItem('savedSearches') || '[]');
|
||||
|
||||
if (savedSearches.length === 0) return;
|
||||
|
||||
const searchForm = document.getElementById('searchForm');
|
||||
const existingSaved = document.getElementById('savedSearches');
|
||||
|
||||
if (existingSaved) {
|
||||
existingSaved.remove();
|
||||
}
|
||||
|
||||
const savedHtml = `
|
||||
<div id="savedSearches" class="mt-3">
|
||||
<h6><i class="fas fa-bookmark me-2"></i>Saved Searches</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
${savedSearches.map(search => `
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
onclick="loadSavedSearch('${escapeHtml(search)}')">
|
||||
${escapeHtml(truncateText(search, 30))}
|
||||
<button class="btn-close ms-2" style="font-size: 0.6em;"
|
||||
onclick="event.stopPropagation(); removeSavedSearch('${escapeHtml(search)}')"></button>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
searchForm.insertAdjacentHTML('afterend', savedHtml);
|
||||
}
|
||||
|
||||
// Load saved search
|
||||
function loadSavedSearch(query) {
|
||||
document.getElementById('searchQuery').value = query;
|
||||
performSearch();
|
||||
}
|
||||
|
||||
// Remove saved search
|
||||
function removeSavedSearch(query) {
|
||||
const savedSearches = JSON.parse(localStorage.getItem('savedSearches') || '[]');
|
||||
const filtered = savedSearches.filter(search => search !== query);
|
||||
localStorage.setItem('savedSearches', JSON.stringify(filtered));
|
||||
updateSavedSearches();
|
||||
showAlert('Saved search removed', 'info');
|
||||
}
|
||||
|
||||
// Initialize saved searches on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Add a small delay to ensure the search form is loaded
|
||||
setTimeout(updateSavedSearches, 100);
|
||||
});
|
||||
318
client/js/teams.js
Normal file
318
client/js/teams.js
Normal file
@ -0,0 +1,318 @@
|
||||
// Team management functionality
|
||||
|
||||
// Load teams
|
||||
async function loadTeams() {
|
||||
if (!config.isConfigured()) {
|
||||
showAlert('Please configure your API settings first.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.getElementById('teamsContainer');
|
||||
container.innerHTML = '<div class="text-center"><div class="loading-spinner"></div> Loading teams...</div>';
|
||||
|
||||
try {
|
||||
const teams = await apiClient.getTeams();
|
||||
displayTeams(teams);
|
||||
} catch (error) {
|
||||
handleApiError(error, 'loading teams');
|
||||
container.innerHTML = '<div class="alert alert-danger">Failed to load teams</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Display teams
|
||||
function displayTeams(teams) {
|
||||
const container = document.getElementById('teamsContainer');
|
||||
|
||||
if (!teams || teams.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-users fa-3x text-muted mb-3"></i>
|
||||
<h4>No teams found</h4>
|
||||
<p class="text-muted">Create your first team to get started!</p>
|
||||
<button class="btn btn-primary" onclick="showCreateTeamModal()">
|
||||
<i class="fas fa-plus me-1"></i>Create Team
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const teamsHtml = `
|
||||
<div class="row">
|
||||
${teams.map(team => `
|
||||
<div class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<i class="fas fa-users me-2 text-primary"></i>
|
||||
${escapeHtml(team.name)}
|
||||
</h5>
|
||||
<p class="card-text">${escapeHtml(team.description || 'No description')}</p>
|
||||
<div class="text-muted small mb-3">
|
||||
<i class="fas fa-calendar me-1"></i>Created: ${formatDate(team.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="btn-group w-100" role="group">
|
||||
<button class="btn btn-outline-primary" onclick="viewTeam('${team.id}')">
|
||||
<i class="fas fa-eye"></i> View
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="editTeam('${team.id}')">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</button>
|
||||
<button class="btn btn-outline-danger" onclick="deleteTeam('${team.id}')">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = teamsHtml;
|
||||
}
|
||||
|
||||
// Show create team modal
|
||||
function showCreateTeamModal() {
|
||||
const modalBody = `
|
||||
<form id="createTeamForm">
|
||||
<div class="mb-3">
|
||||
<label for="teamName" class="form-label">Team Name *</label>
|
||||
<input type="text" class="form-control" id="teamName" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="teamDescription" class="form-label">Description</label>
|
||||
<textarea class="form-control" id="teamDescription" rows="3"
|
||||
placeholder="Describe this team's purpose..."></textarea>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const modalFooter = `
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="createTeam()">
|
||||
<i class="fas fa-plus me-1"></i>Create Team
|
||||
</button>
|
||||
`;
|
||||
|
||||
const modal = createModal('createTeamModal', 'Create New Team', modalBody, modalFooter);
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('createTeamForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
createTeam();
|
||||
});
|
||||
|
||||
modal.show();
|
||||
|
||||
// Focus on name field
|
||||
setTimeout(() => document.getElementById('teamName').focus(), 100);
|
||||
}
|
||||
|
||||
// Create team
|
||||
async function createTeam() {
|
||||
const name = document.getElementById('teamName').value.trim();
|
||||
const description = document.getElementById('teamDescription').value.trim();
|
||||
|
||||
if (!name) {
|
||||
showAlert('Team name is required', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const createButton = document.querySelector('#createTeamModal .btn-primary');
|
||||
setLoadingState(createButton);
|
||||
|
||||
try {
|
||||
await apiClient.createTeam({
|
||||
name,
|
||||
description
|
||||
});
|
||||
|
||||
showAlert('Team created successfully!', 'success');
|
||||
|
||||
// Close modal and refresh teams
|
||||
bootstrap.Modal.getInstance(document.getElementById('createTeamModal')).hide();
|
||||
removeModal('createTeamModal');
|
||||
loadTeams();
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'creating team');
|
||||
} finally {
|
||||
setLoadingState(createButton, false);
|
||||
}
|
||||
}
|
||||
|
||||
// View team details
|
||||
async function viewTeam(teamId) {
|
||||
try {
|
||||
const teams = await apiClient.getTeams();
|
||||
const team = teams.find(t => t.id === teamId);
|
||||
|
||||
if (!team) {
|
||||
showAlert('Team not found', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const modalBody = `
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h5><i class="fas fa-users me-2"></i>${escapeHtml(team.name)}</h5>
|
||||
<p class="text-muted">${escapeHtml(team.description || 'No description')}</p>
|
||||
<hr>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Team Information</h6>
|
||||
<p><strong>Created:</strong> ${formatDate(team.created_at)}</p>
|
||||
<p><strong>ID:</strong> <code>${team.id}</code></p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Statistics</h6>
|
||||
<p><strong>Members:</strong> <span id="teamMemberCount">Loading...</span></p>
|
||||
<p><strong>Status:</strong> <span class="badge bg-success">Active</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modalFooter = `
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" onclick="editTeam('${teamId}')">
|
||||
<i class="fas fa-edit me-1"></i>Edit Team
|
||||
</button>
|
||||
`;
|
||||
|
||||
const modal = createModal('viewTeamModal', 'Team Details', modalBody, modalFooter);
|
||||
modal.show();
|
||||
|
||||
// Load team member count
|
||||
loadTeamMemberCount(teamId);
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'loading team details');
|
||||
}
|
||||
}
|
||||
|
||||
// Load team member count
|
||||
async function loadTeamMemberCount(teamId) {
|
||||
try {
|
||||
const users = await apiClient.getUsers();
|
||||
const teamMembers = users.filter(user => user.team_id === teamId);
|
||||
document.getElementById('teamMemberCount').textContent = teamMembers.length;
|
||||
} catch (error) {
|
||||
document.getElementById('teamMemberCount').textContent = 'Error loading';
|
||||
}
|
||||
}
|
||||
|
||||
// Edit team
|
||||
async function editTeam(teamId) {
|
||||
try {
|
||||
const teams = await apiClient.getTeams();
|
||||
const team = teams.find(t => t.id === teamId);
|
||||
|
||||
if (!team) {
|
||||
showAlert('Team not found', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const modalBody = `
|
||||
<form id="editTeamForm">
|
||||
<div class="mb-3">
|
||||
<label for="editTeamName" class="form-label">Team Name *</label>
|
||||
<input type="text" class="form-control" id="editTeamName"
|
||||
value="${escapeHtml(team.name)}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editTeamDescription" class="form-label">Description</label>
|
||||
<textarea class="form-control" id="editTeamDescription" rows="3">${escapeHtml(team.description || '')}</textarea>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const modalFooter = `
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveTeamChanges('${teamId}')">
|
||||
<i class="fas fa-save me-1"></i>Save Changes
|
||||
</button>
|
||||
`;
|
||||
|
||||
const modal = createModal('editTeamModal', 'Edit Team', modalBody, modalFooter);
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('editTeamForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
saveTeamChanges(teamId);
|
||||
});
|
||||
|
||||
modal.show();
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'loading team for editing');
|
||||
}
|
||||
}
|
||||
|
||||
// Save team changes
|
||||
async function saveTeamChanges(teamId) {
|
||||
const name = document.getElementById('editTeamName').value.trim();
|
||||
const description = document.getElementById('editTeamDescription').value.trim();
|
||||
|
||||
if (!name) {
|
||||
showAlert('Team name is required', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const saveButton = document.querySelector('#editTeamModal .btn-primary');
|
||||
setLoadingState(saveButton);
|
||||
|
||||
try {
|
||||
await apiClient.updateTeam(teamId, {
|
||||
name,
|
||||
description
|
||||
});
|
||||
|
||||
showAlert('Team updated successfully!', 'success');
|
||||
|
||||
// Close modal and refresh teams
|
||||
bootstrap.Modal.getInstance(document.getElementById('editTeamModal')).hide();
|
||||
removeModal('editTeamModal');
|
||||
loadTeams();
|
||||
|
||||
// Close view modal if open
|
||||
const viewModal = document.getElementById('viewTeamModal');
|
||||
if (viewModal) {
|
||||
bootstrap.Modal.getInstance(viewModal)?.hide();
|
||||
removeModal('viewTeamModal');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'updating team');
|
||||
} finally {
|
||||
setLoadingState(saveButton, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete team
|
||||
function deleteTeam(teamId) {
|
||||
confirmAction('Are you sure you want to delete this team? This action cannot be undone and will affect all team members.', async () => {
|
||||
try {
|
||||
await apiClient.deleteTeam(teamId);
|
||||
showAlert('Team deleted successfully!', 'success');
|
||||
loadTeams();
|
||||
|
||||
// Close any open modals
|
||||
const modals = ['viewTeamModal', 'editTeamModal'];
|
||||
modals.forEach(modalId => {
|
||||
const modalElement = document.getElementById(modalId);
|
||||
if (modalElement) {
|
||||
bootstrap.Modal.getInstance(modalElement)?.hide();
|
||||
removeModal(modalId);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'deleting team');
|
||||
}
|
||||
});
|
||||
}
|
||||
280
client/js/ui.js
Normal file
280
client/js/ui.js
Normal file
@ -0,0 +1,280 @@
|
||||
// UI utilities and common functions
|
||||
|
||||
// Page navigation
|
||||
function showPage(pageId) {
|
||||
// Hide all pages
|
||||
const pages = document.querySelectorAll('.page');
|
||||
pages.forEach(page => {
|
||||
page.style.display = 'none';
|
||||
});
|
||||
|
||||
// Show the selected page
|
||||
const targetPage = document.getElementById(pageId + 'Page');
|
||||
if (targetPage) {
|
||||
targetPage.style.display = 'block';
|
||||
|
||||
// Update URL hash
|
||||
window.location.hash = pageId;
|
||||
|
||||
// Update navigation active state
|
||||
updateNavActiveState(pageId);
|
||||
|
||||
// Load page data if needed
|
||||
loadPageData(pageId);
|
||||
}
|
||||
}
|
||||
|
||||
// Update navigation active state
|
||||
function updateNavActiveState(activePageId) {
|
||||
const navLinks = document.querySelectorAll('.navbar-nav .nav-link');
|
||||
navLinks.forEach(link => {
|
||||
link.classList.remove('active');
|
||||
const onclick = link.getAttribute('onclick');
|
||||
if (onclick && onclick.includes(`'${activePageId}'`)) {
|
||||
link.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load page-specific data
|
||||
function loadPageData(pageId) {
|
||||
switch (pageId) {
|
||||
case 'config':
|
||||
initializeConfigForm();
|
||||
break;
|
||||
case 'images':
|
||||
loadImages();
|
||||
break;
|
||||
case 'teams':
|
||||
loadTeams();
|
||||
break;
|
||||
case 'users':
|
||||
loadUsers();
|
||||
break;
|
||||
case 'api-keys':
|
||||
loadApiKeys();
|
||||
break;
|
||||
case 'search':
|
||||
initializeSearchForm();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Alert system
|
||||
function showAlert(message, type = 'info', persistent = false) {
|
||||
const alertContainer = document.getElementById('alertContainer');
|
||||
|
||||
const alertId = 'alert-' + Date.now();
|
||||
const alertHtml = `
|
||||
<div id="${alertId}" class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
alertContainer.insertAdjacentHTML('beforeend', alertHtml);
|
||||
|
||||
// Auto-dismiss non-persistent alerts
|
||||
if (!persistent) {
|
||||
setTimeout(() => {
|
||||
const alertElement = document.getElementById(alertId);
|
||||
if (alertElement) {
|
||||
const bsAlert = new bootstrap.Alert(alertElement);
|
||||
bsAlert.close();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all alerts
|
||||
function clearAlerts() {
|
||||
const alertContainer = document.getElementById('alertContainer');
|
||||
alertContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
// Modal utilities
|
||||
function createModal(id, title, body, footer = '') {
|
||||
const modalHtml = `
|
||||
<div class="modal fade" id="${id}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">${title}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
${body}
|
||||
</div>
|
||||
${footer ? `<div class="modal-footer">${footer}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modalContainer = document.getElementById('modalContainer');
|
||||
modalContainer.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
return new bootstrap.Modal(document.getElementById(id));
|
||||
}
|
||||
|
||||
// Remove modal from DOM
|
||||
function removeModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state utilities
|
||||
function setLoadingState(element, loading = true) {
|
||||
if (loading) {
|
||||
element.disabled = true;
|
||||
element.dataset.originalText = element.innerHTML;
|
||||
element.innerHTML = '<span class="loading-spinner"></span> Loading...';
|
||||
} else {
|
||||
element.disabled = false;
|
||||
element.innerHTML = element.dataset.originalText || element.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
// Format file size
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Truncate text
|
||||
function truncateText(text, maxLength = 100) {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
// Escape HTML
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Debounce function
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Confirm dialog
|
||||
function confirmAction(message, callback) {
|
||||
if (confirm(message)) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
// Copy to clipboard
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showAlert('Copied to clipboard!', 'success');
|
||||
} catch (err) {
|
||||
console.error('Failed to copy: ', err);
|
||||
showAlert('Failed to copy to clipboard', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize tooltips
|
||||
function initializeTooltips() {
|
||||
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize popovers
|
||||
function initializePopovers() {
|
||||
const popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
|
||||
popoverTriggerList.map(function (popoverTriggerEl) {
|
||||
return new bootstrap.Popover(popoverTriggerEl);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle file drag and drop
|
||||
function setupFileDropZone(element, callback) {
|
||||
element.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
element.classList.add('dragover');
|
||||
});
|
||||
|
||||
element.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
element.classList.remove('dragover');
|
||||
});
|
||||
|
||||
element.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
element.classList.remove('dragover');
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
callback(files);
|
||||
});
|
||||
}
|
||||
|
||||
// Validate file type and size
|
||||
function validateFile(file, allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], maxSize = 10 * 1024 * 1024) {
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
throw new Error(`File type ${file.type} is not allowed. Allowed types: ${allowedTypes.join(', ')}`);
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
throw new Error(`File size ${formatFileSize(file.size)} exceeds maximum allowed size of ${formatFileSize(maxSize)}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create image preview
|
||||
function createImagePreview(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle hash changes for navigation
|
||||
window.addEventListener('hashchange', () => {
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash) {
|
||||
showPage(hash);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize page on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize tooltips and popovers
|
||||
initializeTooltips();
|
||||
initializePopovers();
|
||||
|
||||
// Update navigation state
|
||||
updateNavigationState();
|
||||
|
||||
// Show initial page
|
||||
const hash = window.location.hash.substring(1);
|
||||
const initialPage = hash || 'home';
|
||||
showPage(initialPage);
|
||||
});
|
||||
471
client/js/users.js
Normal file
471
client/js/users.js
Normal file
@ -0,0 +1,471 @@
|
||||
// User management functionality
|
||||
|
||||
// Load users
|
||||
async function loadUsers() {
|
||||
if (!config.isConfigured()) {
|
||||
showAlert('Please configure your API settings first.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.getElementById('usersContainer');
|
||||
container.innerHTML = '<div class="text-center"><div class="loading-spinner"></div> Loading users...</div>';
|
||||
|
||||
try {
|
||||
const users = await apiClient.getUsers();
|
||||
displayUsers(users);
|
||||
} catch (error) {
|
||||
handleApiError(error, 'loading users');
|
||||
container.innerHTML = '<div class="alert alert-danger">Failed to load users</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Display users
|
||||
function displayUsers(users) {
|
||||
const container = document.getElementById('usersContainer');
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-user fa-3x text-muted mb-3"></i>
|
||||
<h4>No users found</h4>
|
||||
<p class="text-muted">Create your first user to get started!</p>
|
||||
<button class="btn btn-primary" onclick="showCreateUserModal()">
|
||||
<i class="fas fa-plus me-1"></i>Create User
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const usersHtml = `
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Team</th>
|
||||
<th>Role</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${users.map(user => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-circle me-2">
|
||||
${user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
${escapeHtml(user.name)}
|
||||
</div>
|
||||
</td>
|
||||
<td>${escapeHtml(user.email)}</td>
|
||||
<td>
|
||||
<span class="badge bg-info" id="team-${user.team_id}">
|
||||
Loading...
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge ${user.is_admin ? 'bg-danger' : 'bg-secondary'}">
|
||||
${user.is_admin ? 'Admin' : 'User'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted small">${formatDate(user.created_at)}</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="viewUser('${user.id}')">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="editUser('${user.id}')">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteUser('${user.id}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = usersHtml;
|
||||
|
||||
// Load team names
|
||||
loadTeamNames();
|
||||
}
|
||||
|
||||
// Load team names for display
|
||||
async function loadTeamNames() {
|
||||
try {
|
||||
const teams = await apiClient.getTeams();
|
||||
teams.forEach(team => {
|
||||
const teamBadges = document.querySelectorAll(`#team-${team.id}`);
|
||||
teamBadges.forEach(badge => {
|
||||
badge.textContent = team.name;
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load team names:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Show create user modal
|
||||
async function showCreateUserModal() {
|
||||
try {
|
||||
const teams = await apiClient.getTeams();
|
||||
|
||||
const modalBody = `
|
||||
<form id="createUserForm">
|
||||
<div class="mb-3">
|
||||
<label for="userName" class="form-label">Full Name *</label>
|
||||
<input type="text" class="form-control" id="userName" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="userEmail" class="form-label">Email *</label>
|
||||
<input type="email" class="form-control" id="userEmail" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="userPassword" class="form-label">Password *</label>
|
||||
<input type="password" class="form-control" id="userPassword" required>
|
||||
<div class="form-text">Minimum 8 characters</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="userTeam" class="form-label">Team *</label>
|
||||
<select class="form-select" id="userTeam" required>
|
||||
<option value="">Select a team...</option>
|
||||
${teams.map(team => `
|
||||
<option value="${team.id}">${escapeHtml(team.name)}</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="userIsAdmin">
|
||||
<label class="form-check-label" for="userIsAdmin">
|
||||
Administrator privileges
|
||||
</label>
|
||||
<div class="form-text">Admins can manage teams, users, and system settings</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const modalFooter = `
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="createUser()">
|
||||
<i class="fas fa-plus me-1"></i>Create User
|
||||
</button>
|
||||
`;
|
||||
|
||||
const modal = createModal('createUserModal', 'Create New User', modalBody, modalFooter);
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('createUserForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
createUser();
|
||||
});
|
||||
|
||||
modal.show();
|
||||
|
||||
// Focus on name field
|
||||
setTimeout(() => document.getElementById('userName').focus(), 100);
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'loading teams for user creation');
|
||||
}
|
||||
}
|
||||
|
||||
// Create user
|
||||
async function createUser() {
|
||||
const name = document.getElementById('userName').value.trim();
|
||||
const email = document.getElementById('userEmail').value.trim();
|
||||
const password = document.getElementById('userPassword').value;
|
||||
const teamId = document.getElementById('userTeam').value;
|
||||
const isAdmin = document.getElementById('userIsAdmin').checked;
|
||||
|
||||
if (!name || !email || !password || !teamId) {
|
||||
showAlert('All fields are required', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
showAlert('Password must be at least 8 characters long', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const createButton = document.querySelector('#createUserModal .btn-primary');
|
||||
setLoadingState(createButton);
|
||||
|
||||
try {
|
||||
await apiClient.createUser({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
team_id: teamId,
|
||||
is_admin: isAdmin
|
||||
});
|
||||
|
||||
showAlert('User created successfully!', 'success');
|
||||
|
||||
// Close modal and refresh users
|
||||
bootstrap.Modal.getInstance(document.getElementById('createUserModal')).hide();
|
||||
removeModal('createUserModal');
|
||||
loadUsers();
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'creating user');
|
||||
} finally {
|
||||
setLoadingState(createButton, false);
|
||||
}
|
||||
}
|
||||
|
||||
// View user details
|
||||
async function viewUser(userId) {
|
||||
try {
|
||||
const users = await apiClient.getUsers();
|
||||
const user = users.find(u => u.id === userId);
|
||||
|
||||
if (!user) {
|
||||
showAlert('User not found', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const teams = await apiClient.getTeams();
|
||||
const userTeam = teams.find(t => t.id === user.team_id);
|
||||
|
||||
const modalBody = `
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="text-center mb-4">
|
||||
<div class="avatar-circle-large mx-auto mb-3">
|
||||
${user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<h5>${escapeHtml(user.name)}</h5>
|
||||
<p class="text-muted">${escapeHtml(user.email)}</p>
|
||||
<span class="badge ${user.is_admin ? 'bg-danger' : 'bg-secondary'} fs-6">
|
||||
${user.is_admin ? 'Administrator' : 'User'}
|
||||
</span>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>User Information</h6>
|
||||
<p><strong>Team:</strong> ${userTeam ? escapeHtml(userTeam.name) : 'Unknown'}</p>
|
||||
<p><strong>Created:</strong> ${formatDate(user.created_at)}</p>
|
||||
<p><strong>ID:</strong> <code>${user.id}</code></p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Permissions</h6>
|
||||
<p><strong>Admin Access:</strong> ${user.is_admin ? 'Yes' : 'No'}</p>
|
||||
<p><strong>Status:</strong> <span class="badge bg-success">Active</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modalFooter = `
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" onclick="editUser('${userId}')">
|
||||
<i class="fas fa-edit me-1"></i>Edit User
|
||||
</button>
|
||||
`;
|
||||
|
||||
const modal = createModal('viewUserModal', 'User Details', modalBody, modalFooter);
|
||||
modal.show();
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'loading user details');
|
||||
}
|
||||
}
|
||||
|
||||
// Edit user
|
||||
async function editUser(userId) {
|
||||
try {
|
||||
const users = await apiClient.getUsers();
|
||||
const user = users.find(u => u.id === userId);
|
||||
|
||||
if (!user) {
|
||||
showAlert('User not found', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const teams = await apiClient.getTeams();
|
||||
|
||||
const modalBody = `
|
||||
<form id="editUserForm">
|
||||
<div class="mb-3">
|
||||
<label for="editUserName" class="form-label">Full Name *</label>
|
||||
<input type="text" class="form-control" id="editUserName"
|
||||
value="${escapeHtml(user.name)}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editUserEmail" class="form-label">Email *</label>
|
||||
<input type="email" class="form-control" id="editUserEmail"
|
||||
value="${escapeHtml(user.email)}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editUserTeam" class="form-label">Team *</label>
|
||||
<select class="form-select" id="editUserTeam" required>
|
||||
${teams.map(team => `
|
||||
<option value="${team.id}" ${team.id === user.team_id ? 'selected' : ''}>
|
||||
${escapeHtml(team.name)}
|
||||
</option>
|
||||
`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="editUserIsAdmin"
|
||||
${user.is_admin ? 'checked' : ''}>
|
||||
<label class="form-check-label" for="editUserIsAdmin">
|
||||
Administrator privileges
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editUserPassword" class="form-label">New Password</label>
|
||||
<input type="password" class="form-control" id="editUserPassword">
|
||||
<div class="form-text">Leave blank to keep current password</div>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const modalFooter = `
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveUserChanges('${userId}')">
|
||||
<i class="fas fa-save me-1"></i>Save Changes
|
||||
</button>
|
||||
`;
|
||||
|
||||
const modal = createModal('editUserModal', 'Edit User', modalBody, modalFooter);
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('editUserForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
saveUserChanges(userId);
|
||||
});
|
||||
|
||||
modal.show();
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'loading user for editing');
|
||||
}
|
||||
}
|
||||
|
||||
// Save user changes
|
||||
async function saveUserChanges(userId) {
|
||||
const name = document.getElementById('editUserName').value.trim();
|
||||
const email = document.getElementById('editUserEmail').value.trim();
|
||||
const teamId = document.getElementById('editUserTeam').value;
|
||||
const isAdmin = document.getElementById('editUserIsAdmin').checked;
|
||||
const password = document.getElementById('editUserPassword').value;
|
||||
|
||||
if (!name || !email || !teamId) {
|
||||
showAlert('Name, email, and team are required', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
const saveButton = document.querySelector('#editUserModal .btn-primary');
|
||||
setLoadingState(saveButton);
|
||||
|
||||
try {
|
||||
const updateData = {
|
||||
name,
|
||||
email,
|
||||
team_id: teamId,
|
||||
is_admin: isAdmin
|
||||
};
|
||||
|
||||
if (password) {
|
||||
if (password.length < 8) {
|
||||
showAlert('Password must be at least 8 characters long', 'danger');
|
||||
return;
|
||||
}
|
||||
updateData.password = password;
|
||||
}
|
||||
|
||||
await apiClient.updateUser(userId, updateData);
|
||||
|
||||
showAlert('User updated successfully!', 'success');
|
||||
|
||||
// Close modal and refresh users
|
||||
bootstrap.Modal.getInstance(document.getElementById('editUserModal')).hide();
|
||||
removeModal('editUserModal');
|
||||
loadUsers();
|
||||
|
||||
// Close view modal if open
|
||||
const viewModal = document.getElementById('viewUserModal');
|
||||
if (viewModal) {
|
||||
bootstrap.Modal.getInstance(viewModal)?.hide();
|
||||
removeModal('viewUserModal');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'updating user');
|
||||
} finally {
|
||||
setLoadingState(saveButton, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user
|
||||
function deleteUser(userId) {
|
||||
confirmAction('Are you sure you want to delete this user? This action cannot be undone.', async () => {
|
||||
try {
|
||||
await apiClient.deleteUser(userId);
|
||||
showAlert('User deleted successfully!', 'success');
|
||||
loadUsers();
|
||||
|
||||
// Close any open modals
|
||||
const modals = ['viewUserModal', 'editUserModal'];
|
||||
modals.forEach(modalId => {
|
||||
const modalElement = document.getElementById(modalId);
|
||||
if (modalElement) {
|
||||
bootstrap.Modal.getInstance(modalElement)?.hide();
|
||||
removeModal(modalId);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'deleting user');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add CSS for avatar circles
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.avatar-circle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.avatar-circle-large {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 32px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
});
|
||||
@ -1,3 +1,22 @@
|
||||
Flask==2.3.3
|
||||
requests==2.31.0
|
||||
Werkzeug==2.3.7
|
||||
# SeReact Frontend Client
|
||||
#
|
||||
# This is a pure frontend application that runs in the browser.
|
||||
# No Python dependencies are required.
|
||||
#
|
||||
# To serve the files locally for development, you can use:
|
||||
#
|
||||
# Python 3:
|
||||
# python -m http.server 8080
|
||||
#
|
||||
# Python 2:
|
||||
# python -m SimpleHTTPServer 8080
|
||||
#
|
||||
# Node.js:
|
||||
# npx http-server -p 8080
|
||||
#
|
||||
# Or deploy to any static hosting service like:
|
||||
# - Netlify
|
||||
# - Vercel
|
||||
# - GitHub Pages
|
||||
# - AWS S3
|
||||
# - Azure Static Web Apps
|
||||
75
client/serve.py
Normal file
75
client/serve.py
Normal file
@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple HTTP server for serving the SeReact frontend during development.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import socketserver
|
||||
import webbrowser
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Configuration
|
||||
PORT = 8080
|
||||
HOST = 'localhost'
|
||||
|
||||
class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
|
||||
"""Custom handler to serve index.html for SPA routing"""
|
||||
|
||||
def end_headers(self):
|
||||
# Add CORS headers for development
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key')
|
||||
super().end_headers()
|
||||
|
||||
def do_OPTIONS(self):
|
||||
# Handle preflight requests
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
|
||||
def main():
|
||||
# Change to the directory containing this script
|
||||
script_dir = Path(__file__).parent
|
||||
os.chdir(script_dir)
|
||||
|
||||
# Check if index.html exists
|
||||
if not Path('index.html').exists():
|
||||
print("Error: index.html not found in current directory")
|
||||
print(f"Current directory: {os.getcwd()}")
|
||||
sys.exit(1)
|
||||
|
||||
# Create server
|
||||
with socketserver.TCPServer((HOST, PORT), CustomHTTPRequestHandler) as httpd:
|
||||
server_url = f"http://{HOST}:{PORT}"
|
||||
|
||||
print(f"🚀 SeReact Frontend Development Server")
|
||||
print(f"📁 Serving files from: {os.getcwd()}")
|
||||
print(f"🌐 Server running at: {server_url}")
|
||||
print(f"📱 Open in browser: {server_url}")
|
||||
print(f"⏹️ Press Ctrl+C to stop the server")
|
||||
print()
|
||||
|
||||
# Try to open browser automatically
|
||||
try:
|
||||
webbrowser.open(server_url)
|
||||
print("✅ Browser opened automatically")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not open browser automatically: {e}")
|
||||
|
||||
print()
|
||||
print("🔧 Development Tips:")
|
||||
print(" - Edit files and refresh browser to see changes")
|
||||
print(" - Check browser console (F12) for errors")
|
||||
print(" - Configure API settings in the app")
|
||||
print()
|
||||
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n🛑 Server stopped by user")
|
||||
print("👋 Thanks for using SeReact!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
300
client/styles.css
Normal file
300
client/styles.css
Normal file
@ -0,0 +1,300 @@
|
||||
/* Custom styles for SeReact Frontend */
|
||||
|
||||
:root {
|
||||
--primary-color: #0d6efd;
|
||||
--secondary-color: #6c757d;
|
||||
--success-color: #198754;
|
||||
--info-color: #0dcaf0;
|
||||
--warning-color: #ffc107;
|
||||
--danger-color: #dc3545;
|
||||
--light-color: #f8f9fa;
|
||||
--dark-color: #212529;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 70vh;
|
||||
}
|
||||
|
||||
.jumbotron {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
transition: box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.image-card {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.image-card:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.image-card img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
border-left: 4px solid var(--primary-color);
|
||||
background-color: white;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.similarity-score {
|
||||
background: linear-gradient(90deg, var(--success-color), var(--warning-color), var(--danger-color));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.alert {
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.form-control, .form-select {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #dee2e6;
|
||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
.table {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
background-color: var(--light-color);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.75em;
|
||||
padding: 0.5em 0.75em;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
border-radius: 6px;
|
||||
margin: 0 2px;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.image-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.jumbotron {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.display-4 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--secondary-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Animation for page transitions */
|
||||
.page {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* File upload area */
|
||||
.upload-area {
|
||||
border: 2px dashed var(--primary-color);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background-color: rgba(13, 110, 253, 0.05);
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
background-color: rgba(13, 110, 253, 0.1);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.upload-area.dragover {
|
||||
background-color: rgba(13, 110, 253, 0.15);
|
||||
border-color: var(--primary-color);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status-online {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
/* Tag styles */
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background-color: var(--light-color);
|
||||
color: var(--dark-color);
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin: 0.125rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid var(--secondary-color);
|
||||
}
|
||||
|
||||
.tag-input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* Progress bars */
|
||||
.progress {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--light-color);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.text-muted-light {
|
||||
color: #6c757d !important;
|
||||
}
|
||||
|
||||
.bg-gradient-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color), #0056b3);
|
||||
}
|
||||
|
||||
.bg-gradient-success {
|
||||
background: linear-gradient(135deg, var(--success-color), #146c43);
|
||||
}
|
||||
|
||||
.bg-gradient-info {
|
||||
background: linear-gradient(135deg, var(--info-color), #0aa2c0);
|
||||
}
|
||||
|
||||
.shadow-sm-hover:hover {
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-select-none {
|
||||
user-select: none;
|
||||
}
|
||||
@ -1,111 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create API Key - SEREACT Web Client{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-key"></i> Create API Key
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">
|
||||
<i class="fas fa-tag"></i> Key Name *
|
||||
</label>
|
||||
<input type="text" class="form-control" id="name" name="name"
|
||||
placeholder="Enter API key name" required>
|
||||
<div class="form-text">
|
||||
A descriptive name for this API key
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">
|
||||
<i class="fas fa-align-left"></i> Description
|
||||
</label>
|
||||
<textarea class="form-control" id="description" name="description"
|
||||
rows="3" placeholder="Enter description (optional)"></textarea>
|
||||
<div class="form-text">
|
||||
Optional description of the key's purpose or usage
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="team_id" class="form-label">
|
||||
<i class="fas fa-users"></i> Team (Admin Only)
|
||||
</label>
|
||||
<select class="form-select" id="team_id" name="team_id">
|
||||
<option value="">Use current user's team</option>
|
||||
{% for team in teams %}
|
||||
<option value="{{ team.id }}">{{ team.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Leave empty to use your current team. Admin users can select any team.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="user_id" class="form-label">
|
||||
<i class="fas fa-user"></i> User (Admin Only)
|
||||
</label>
|
||||
<select class="form-select" id="user_id" name="user_id">
|
||||
<option value="">Use current user</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}">{{ user.name }} ({{ user.email }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Leave empty to create the key for yourself. Admin users can create keys for other users.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{{ url_for('api_keys') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to API Keys
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-key"></i> Create API Key
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-info-circle"></i> Important Information
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-shield-alt text-warning"></i> Security</h6>
|
||||
<ul class="small">
|
||||
<li>API keys provide full access to your account</li>
|
||||
<li>The key value will only be shown once</li>
|
||||
<li>Store the key securely and never share it</li>
|
||||
<li>Revoke keys that are no longer needed</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-clock text-info"></i> Expiration</h6>
|
||||
<ul class="small">
|
||||
<li>API keys have automatic expiration dates</li>
|
||||
<li>You'll need to create new keys when they expire</li>
|
||||
<li>Monitor key usage and expiration dates</li>
|
||||
<li>Inactive keys are automatically disabled</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,101 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}API Keys - SEREACT Web Client{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-key"></i> API Keys</h2>
|
||||
<a href="{{ url_for('create_api_key') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Create API Key
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if api_keys %}
|
||||
<div class="row">
|
||||
{% for key in api_keys %}
|
||||
<div class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<i class="fas fa-key text-primary"></i> {{ key.name }}
|
||||
{% if not key.is_active %}
|
||||
<span class="badge bg-danger ms-2">Inactive</span>
|
||||
{% endif %}
|
||||
</h5>
|
||||
<p class="card-text">{{ key.description or 'No description provided' }}</p>
|
||||
<div class="text-muted small">
|
||||
<div><i class="fas fa-users"></i> Team ID: {{ key.team_id }}</div>
|
||||
<div><i class="fas fa-user"></i> User ID: {{ key.user_id }}</div>
|
||||
<div><i class="fas fa-calendar"></i> Created: {{ key.created_at.strftime('%Y-%m-%d %H:%M') if key.created_at else 'Unknown' }}</div>
|
||||
{% if key.expiry_date %}
|
||||
<div><i class="fas fa-clock"></i> Expires: {{ key.expiry_date.strftime('%Y-%m-%d %H:%M') }}</div>
|
||||
{% endif %}
|
||||
{% if key.last_used %}
|
||||
<div><i class="fas fa-history"></i> Last used: {{ key.last_used.strftime('%Y-%m-%d %H:%M') }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="d-grid">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
onclick="confirmDelete('{{ key.name }}', '{{ url_for('delete_api_key', key_id=key.id) }}')">
|
||||
<i class="fas fa-trash"></i> Revoke Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-key fa-4x text-muted mb-3"></i>
|
||||
<h4 class="text-muted">No API Keys Found</h4>
|
||||
<p class="text-muted">Create your first API key to get started.</p>
|
||||
<a href="{{ url_for('create_api_key') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Create First API Key
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-exclamation-triangle text-warning"></i> Confirm Revoke
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to revoke the API key "<span id="deleteKeyName"></span>"?</p>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Warning:</strong> This action cannot be undone. Applications using this key will lose access immediately.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times"></i> Cancel
|
||||
</button>
|
||||
<form id="deleteForm" method="POST" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash"></i> Revoke API Key
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function confirmDelete(keyName, deleteUrl) {
|
||||
document.getElementById('deleteKeyName').textContent = keyName;
|
||||
document.getElementById('deleteForm').action = deleteUrl;
|
||||
new bootstrap.Modal(document.getElementById('deleteModal')).show();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,116 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}SEREACT Web Client{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.navbar-brand {
|
||||
font-weight: bold;
|
||||
}
|
||||
.card {
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
}
|
||||
.image-thumbnail {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.search-result-image {
|
||||
max-width: 150px;
|
||||
max-height: 150px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.similarity-score {
|
||||
background: linear-gradient(90deg, #28a745 0%, #ffc107 50%, #dc3545 100%);
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{{ url_for('index') }}">
|
||||
<i class="fas fa-images"></i> SEREACT
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('index') }}">
|
||||
<i class="fas fa-home"></i> Home
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('images') }}">
|
||||
<i class="fas fa-images"></i> Images
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('search') }}">
|
||||
<i class="fas fa-search"></i> Search
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown">
|
||||
<i class="fas fa-cog"></i> Management
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{{ url_for('teams') }}">
|
||||
<i class="fas fa-users"></i> Teams
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('users') }}">
|
||||
<i class="fas fa-user"></i> Users
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('api_keys') }}">
|
||||
<i class="fas fa-key"></i> API Keys
|
||||
</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('config') }}">
|
||||
<i class="fas fa-cog"></i> Config
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('bootstrap') }}">
|
||||
<i class="fas fa-rocket"></i> Bootstrap
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container mt-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="bg-light mt-5 py-4">
|
||||
<div class="container text-center">
|
||||
<p class="text-muted mb-0">SEREACT Web Client - Secure Image Management</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@ -1,140 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Bootstrap - SEREACT Web Client{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-rocket"></i> Bootstrap Initial Setup
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<strong>First Time Setup:</strong> This will create the initial team, admin user, and API key for your SEREACT system.
|
||||
This should only be used once during initial setup.
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="team_name" class="form-label">
|
||||
<i class="fas fa-users"></i> Team Name
|
||||
</label>
|
||||
<input type="text" class="form-control" id="team_name" name="team_name"
|
||||
placeholder="My Organization" required>
|
||||
<div class="form-text">
|
||||
The name of your initial team/organization
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="admin_name" class="form-label">
|
||||
<i class="fas fa-user"></i> Admin Name
|
||||
</label>
|
||||
<input type="text" class="form-control" id="admin_name" name="admin_name"
|
||||
placeholder="John Doe" required>
|
||||
<div class="form-text">
|
||||
Full name of the administrator user
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="admin_email" class="form-label">
|
||||
<i class="fas fa-envelope"></i> Admin Email
|
||||
</label>
|
||||
<input type="email" class="form-control" id="admin_email" name="admin_email"
|
||||
placeholder="admin@example.com" required>
|
||||
<div class="form-text">
|
||||
Email address for the administrator user
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="api_key_name" class="form-label">
|
||||
<i class="fas fa-key"></i> API Key Name
|
||||
</label>
|
||||
<input type="text" class="form-control" id="api_key_name" name="api_key_name"
|
||||
value="Initial API Key" placeholder="Initial API Key">
|
||||
<div class="form-text">
|
||||
A descriptive name for the initial API key
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{{ url_for('index') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back
|
||||
</a>
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="fas fa-rocket"></i> Bootstrap System
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-exclamation-triangle"></i> Important Notes
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-shield-alt text-danger"></i> Security</h6>
|
||||
<ul class="small">
|
||||
<li>This endpoint should be disabled in production after initial setup</li>
|
||||
<li>The generated API key will be displayed only once</li>
|
||||
<li>Make sure to save the API key securely</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-cog text-info"></i> What This Creates</h6>
|
||||
<ul class="small">
|
||||
<li>Initial team with the specified name</li>
|
||||
<li>Admin user with full privileges</li>
|
||||
<li>API key for accessing the system</li>
|
||||
<li>Automatic configuration of this client</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-question-circle"></i> Prerequisites
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small mb-2">Before running bootstrap, ensure:</p>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<ul class="small">
|
||||
<li><i class="fas fa-check text-success"></i> SEREACT API server is running</li>
|
||||
<li><i class="fas fa-check text-success"></i> Database (Firestore) is configured</li>
|
||||
<li><i class="fas fa-check text-success"></i> No existing teams in the system</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<ul class="small">
|
||||
<li><i class="fas fa-check text-success"></i> API base URL is configured correctly</li>
|
||||
<li><i class="fas fa-check text-success"></i> Network connectivity to API server</li>
|
||||
<li><i class="fas fa-check text-success"></i> All required services are available</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<a href="{{ url_for('config') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-cog"></i> Check Configuration
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,150 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Configuration - SEREACT Web Client{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-cog"></i> API Configuration
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="api_base_url" class="form-label">
|
||||
<i class="fas fa-link"></i> API Base URL
|
||||
</label>
|
||||
<input type="url" class="form-control" id="api_base_url" name="api_base_url"
|
||||
value="{{ api_base_url }}" placeholder="http://localhost:8000" required>
|
||||
<div class="form-text">
|
||||
The base URL of your SEREACT API server (without /api/v1)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="api_key" class="form-label">
|
||||
<i class="fas fa-key"></i> API Key
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="api_key" name="api_key"
|
||||
value="{{ api_key if api_key else '' }}" placeholder="Your API key">
|
||||
<button class="btn btn-outline-secondary" type="button" id="toggleApiKey">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Your SEREACT API key for authentication
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{{ url_for('index') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-info-circle"></i> Configuration Help
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-server"></i> API Base URL</h6>
|
||||
<p class="small">
|
||||
This should point to your running SEREACT API server. Common examples:
|
||||
</p>
|
||||
<ul class="small">
|
||||
<li><code>http://localhost:8000</code> - Local development</li>
|
||||
<li><code>https://your-api.example.com</code> - Production server</li>
|
||||
<li><code>http://192.168.1.100:8000</code> - Network server</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-key"></i> API Key</h6>
|
||||
<p class="small">
|
||||
You can obtain an API key by:
|
||||
</p>
|
||||
<ul class="small">
|
||||
<li>Using the <a href="{{ url_for('bootstrap') }}">Bootstrap</a> process for initial setup</li>
|
||||
<li>Creating one through the API Keys management page</li>
|
||||
<li>Having an admin create one for you</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if api_key %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-check-circle"></i> Connection Test
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">
|
||||
<i class="fas fa-info-circle text-info"></i>
|
||||
Configuration appears to be set. You can test the connection by trying to access any of the management pages.
|
||||
</p>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('teams') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-users"></i> Test Teams
|
||||
</a>
|
||||
<a href="{{ url_for('users') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-user"></i> Test Users
|
||||
</a>
|
||||
<a href="{{ url_for('images') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-images"></i> Test Images
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-exclamation-triangle"></i> No API Key Set
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">
|
||||
You need to set an API key to use this client. If this is your first time:
|
||||
</p>
|
||||
<a href="{{ url_for('bootstrap') }}" class="btn btn-warning">
|
||||
<i class="fas fa-rocket"></i> Start with Bootstrap
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById('toggleApiKey').addEventListener('click', function() {
|
||||
const apiKeyInput = document.getElementById('api_key');
|
||||
const icon = this.querySelector('i');
|
||||
|
||||
if (apiKeyInput.type === 'password') {
|
||||
apiKeyInput.type = 'text';
|
||||
icon.className = 'fas fa-eye-slash';
|
||||
} else {
|
||||
apiKeyInput.type = 'password';
|
||||
icon.className = 'fas fa-eye';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,246 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ image.filename }} - SEREACT Web Client{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-image"></i> {{ image.filename }}
|
||||
</h5>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('edit_image', image_id=image.id) }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</a>
|
||||
<a href="{{ get_api_base_url() }}/api/v1/images/{{ image.id }}/download"
|
||||
class="btn btn-outline-success btn-sm" target="_blank">
|
||||
<i class="fas fa-download"></i> Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<img src="{{ get_api_base_url() }}/api/v1/images/{{ image.id }}/download"
|
||||
alt="{{ image.filename }}"
|
||||
class="img-fluid rounded"
|
||||
style="max-height: 600px;"
|
||||
onerror="this.src='data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjhmOWZhIiBzdHJva2U9IiNkZWUyZTYiLz48dGV4dCB4PSI1MCUiIHk9IjUwJSIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjE4IiBmaWxsPSIjNmM3NTdkIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkeT0iLjNlbSI+SW1hZ2UgTm90IEZvdW5kPC90ZXh0Pjwvc3ZnPg=='">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<!-- Image Information -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-info-circle"></i> Image Information
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-5">Filename:</dt>
|
||||
<dd class="col-sm-7">{{ image.filename }}</dd>
|
||||
|
||||
<dt class="col-sm-5">Original:</dt>
|
||||
<dd class="col-sm-7">{{ image.original_filename }}</dd>
|
||||
|
||||
<dt class="col-sm-5">Size:</dt>
|
||||
<dd class="col-sm-7">{{ (image.file_size / 1024 / 1024) | round(2) }} MB</dd>
|
||||
|
||||
<dt class="col-sm-5">Type:</dt>
|
||||
<dd class="col-sm-7">{{ image.content_type }}</dd>
|
||||
|
||||
<dt class="col-sm-5">Uploaded:</dt>
|
||||
<dd class="col-sm-7">{{ image.upload_date.strftime('%Y-%m-%d %H:%M') if image.upload_date else 'Unknown' }}</dd>
|
||||
|
||||
<dt class="col-sm-5">Uploader:</dt>
|
||||
<dd class="col-sm-7">{{ image.uploader_id }}</dd>
|
||||
|
||||
<dt class="col-sm-5">Team:</dt>
|
||||
<dd class="col-sm-7">{{ image.team_id }}</dd>
|
||||
|
||||
{% if image.collection_id %}
|
||||
<dt class="col-sm-5">Collection:</dt>
|
||||
<dd class="col-sm-7">{{ image.collection_id }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Status -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-brain"></i> AI Processing Status
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if image.has_embedding %}
|
||||
<div class="alert alert-success" role="alert">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<strong>Ready for Search</strong><br>
|
||||
This image has been processed and is available for AI-powered search.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fas fa-clock"></i>
|
||||
<strong>Processing</strong><br>
|
||||
AI processing is in progress. The image will be searchable once complete.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
{% if image.description %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-align-left"></i> Description
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-0">{{ image.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Tags -->
|
||||
{% if image.tags %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-tags"></i> Tags
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for tag in image.tags %}
|
||||
<span class="badge bg-secondary me-1 mb-1">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Metadata -->
|
||||
{% if image.metadata %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-code"></i> Technical Metadata
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre class="small text-muted mb-0">{{ image.metadata | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-tools"></i> Actions
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('edit_image', image_id=image.id) }}" class="btn btn-primary">
|
||||
<i class="fas fa-edit"></i> Edit Image
|
||||
</a>
|
||||
<a href="{{ get_api_base_url() }}/api/v1/images/{{ image.id }}/download"
|
||||
class="btn btn-success" target="_blank">
|
||||
<i class="fas fa-download"></i> Download Original
|
||||
</a>
|
||||
{% if image.has_embedding %}
|
||||
<button type="button" class="btn btn-info" onclick="findSimilar()">
|
||||
<i class="fas fa-search-plus"></i> Find Similar Images
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
onclick="confirmDelete('{{ image.filename }}', '{{ url_for('delete_image', image_id=image.id) }}')">
|
||||
<i class="fas fa-trash"></i> Delete Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{{ url_for('images') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Images
|
||||
</a>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('upload_image') }}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-upload"></i> Upload Another
|
||||
</a>
|
||||
<a href="{{ url_for('search') }}" class="btn btn-outline-success">
|
||||
<i class="fas fa-search"></i> Search Images
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-exclamation-triangle text-warning"></i> Confirm Delete
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the image "<span id="deleteImageName"></span>"?</p>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Warning:</strong> This action cannot be undone. The image will be permanently removed from storage.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times"></i> Cancel
|
||||
</button>
|
||||
<form id="deleteForm" method="POST" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash"></i> Delete Image
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function confirmDelete(imageName, deleteUrl) {
|
||||
document.getElementById('deleteImageName').textContent = imageName;
|
||||
document.getElementById('deleteForm').action = deleteUrl;
|
||||
new bootstrap.Modal(document.getElementById('deleteModal')).show();
|
||||
}
|
||||
|
||||
function findSimilar() {
|
||||
// Redirect to search with a query to find similar images
|
||||
const searchUrl = "{{ url_for('search') }}";
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = searchUrl;
|
||||
|
||||
const queryInput = document.createElement('input');
|
||||
queryInput.type = 'hidden';
|
||||
queryInput.name = 'query';
|
||||
queryInput.value = 'similar to {{ image.filename }}';
|
||||
|
||||
form.appendChild(queryInput);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,186 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit {{ image.filename }} - SEREACT Web Client{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-edit"></i> Edit Image
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">
|
||||
<i class="fas fa-align-left"></i> Description
|
||||
</label>
|
||||
<textarea class="form-control" id="description" name="description"
|
||||
rows="4" placeholder="Enter image description">{{ image.description or '' }}</textarea>
|
||||
<div class="form-text">
|
||||
A description of what the image contains or represents
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="tags" class="form-label">
|
||||
<i class="fas fa-tags"></i> Tags
|
||||
</label>
|
||||
<input type="text" class="form-control" id="tags" name="tags"
|
||||
value="{{ image.tags | join(', ') if image.tags else '' }}"
|
||||
placeholder="Enter tags separated by commas">
|
||||
<div class="form-text">
|
||||
Tags help organize and search for images. Example: nature, landscape, sunset
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{{ url_for('view_image', image_id=image.id) }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Image
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<!-- Image Preview -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-image"></i> Image Preview
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<img src="{{ get_api_base_url() }}/api/v1/images/{{ image.id }}/download"
|
||||
alt="{{ image.filename }}"
|
||||
class="img-fluid rounded"
|
||||
style="max-height: 300px;"
|
||||
onerror="this.src='data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZjhmOWZhIiBzdHJva2U9IiNkZWUyZTYiLz48dGV4dCB4PSI1MCUiIHk9IjUwJSIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjE2IiBmaWxsPSIjNmM3NTdkIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkeT0iLjNlbSI+SW1hZ2UgTm90IEZvdW5kPC90ZXh0Pjwvc3ZnPg=='">
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">{{ image.filename }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Information -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-info-circle"></i> Current Information
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4">Size:</dt>
|
||||
<dd class="col-sm-8">{{ (image.file_size / 1024 / 1024) | round(2) }} MB</dd>
|
||||
|
||||
<dt class="col-sm-4">Type:</dt>
|
||||
<dd class="col-sm-8">{{ image.content_type }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Uploaded:</dt>
|
||||
<dd class="col-sm-8">{{ image.upload_date.strftime('%Y-%m-%d') if image.upload_date else 'Unknown' }}</dd>
|
||||
|
||||
<dt class="col-sm-4">AI Status:</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% if image.has_embedding %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-check"></i> Processed
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">
|
||||
<i class="fas fa-clock"></i> Processing
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Tags -->
|
||||
{% if image.tags %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-tags"></i> Current Tags
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for tag in image.tags %}
|
||||
<span class="badge bg-secondary me-1 mb-1">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-tools"></i> Quick Actions
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('view_image', image_id=image.id) }}" class="btn btn-outline-primary">
|
||||
<i class="fas fa-eye"></i> View Full Details
|
||||
</a>
|
||||
<a href="{{ get_api_base_url() }}/api/v1/images/{{ image.id }}/download"
|
||||
class="btn btn-outline-success" target="_blank">
|
||||
<i class="fas fa-download"></i> Download Image
|
||||
</a>
|
||||
{% if image.has_embedding %}
|
||||
<a href="{{ url_for('search') }}" class="btn btn-outline-info">
|
||||
<i class="fas fa-search"></i> Search Similar
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-lightbulb"></i> Tips for Better Metadata
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-align-left text-primary"></i> Descriptions</h6>
|
||||
<ul class="small">
|
||||
<li>Describe what you see in the image</li>
|
||||
<li>Include context and important details</li>
|
||||
<li>Mention people, objects, and activities</li>
|
||||
<li>Use natural, descriptive language</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-tags text-success"></i> Tags</h6>
|
||||
<ul class="small">
|
||||
<li>Use specific, descriptive tags</li>
|
||||
<li>Include objects, colors, emotions, locations</li>
|
||||
<li>Separate multiple tags with commas</li>
|
||||
<li>Use consistent naming conventions</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info mt-3" role="alert">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<strong>Note:</strong> Good descriptions and tags improve AI search accuracy and help you find images more easily later.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,168 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Upload Image - SEREACT Web Client{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-upload"></i> Upload Image
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label for="file" class="form-label">
|
||||
<i class="fas fa-file-image"></i> Select Image *
|
||||
</label>
|
||||
<input type="file" class="form-control" id="file" name="file"
|
||||
accept="image/*" required>
|
||||
<div class="form-text">
|
||||
Supported formats: PNG, JPG, JPEG, GIF, BMP, WebP. Maximum size: 10MB.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">
|
||||
<i class="fas fa-align-left"></i> Description
|
||||
</label>
|
||||
<textarea class="form-control" id="description" name="description"
|
||||
rows="3" placeholder="Enter image description (optional)"></textarea>
|
||||
<div class="form-text">
|
||||
A description of what the image contains or represents
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="tags" class="form-label">
|
||||
<i class="fas fa-tags"></i> Tags
|
||||
</label>
|
||||
<input type="text" class="form-control" id="tags" name="tags"
|
||||
placeholder="Enter tags separated by commas">
|
||||
<div class="form-text">
|
||||
Tags help organize and search for images. Example: nature, landscape, sunset
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="collection_id" class="form-label">
|
||||
<i class="fas fa-folder"></i> Collection ID (Optional)
|
||||
</label>
|
||||
<input type="text" class="form-control" id="collection_id" name="collection_id"
|
||||
placeholder="Enter collection ID">
|
||||
<div class="form-text">
|
||||
Optional collection ID to group related images
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{{ url_for('images') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Images
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-upload"></i> Upload Image
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-info-circle"></i> Upload Information
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-brain text-primary"></i> AI Processing</h6>
|
||||
<ul class="small">
|
||||
<li>Images are automatically processed with AI</li>
|
||||
<li>Embeddings are generated for semantic search</li>
|
||||
<li>Processing happens asynchronously in the background</li>
|
||||
<li>You can search for images once processing is complete</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-cloud text-info"></i> Storage</h6>
|
||||
<ul class="small">
|
||||
<li>Images are securely stored in Google Cloud Storage</li>
|
||||
<li>Access is controlled by team membership</li>
|
||||
<li>Metadata is stored in the database</li>
|
||||
<li>Original filenames and formats are preserved</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-lightbulb"></i> Tips for Better Results
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-tags text-warning"></i> Tagging</h6>
|
||||
<ul class="small">
|
||||
<li>Use descriptive, specific tags</li>
|
||||
<li>Include objects, colors, emotions, locations</li>
|
||||
<li>Separate tags with commas</li>
|
||||
<li>Use consistent naming conventions</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-search text-success"></i> Searchability</h6>
|
||||
<ul class="small">
|
||||
<li>Good descriptions improve search accuracy</li>
|
||||
<li>Include context and important details</li>
|
||||
<li>Mention people, objects, and activities</li>
|
||||
<li>Use natural language descriptions</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Preview selected image
|
||||
document.getElementById('file').addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
// Validate file size
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
if (file.size > maxSize) {
|
||||
alert('File size exceeds 10MB limit. Please choose a smaller image.');
|
||||
this.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Show file info
|
||||
const fileInfo = document.createElement('div');
|
||||
fileInfo.className = 'alert alert-info mt-2';
|
||||
fileInfo.innerHTML = `
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<strong>Selected:</strong> ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)
|
||||
`;
|
||||
|
||||
// Remove existing file info
|
||||
const existingInfo = this.parentNode.querySelector('.alert');
|
||||
if (existingInfo) {
|
||||
existingInfo.remove();
|
||||
}
|
||||
|
||||
// Add new file info
|
||||
this.parentNode.appendChild(fileInfo);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,192 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Images - SEREACT Web Client{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-images"></i> Images</h2>
|
||||
<a href="{{ url_for('upload_image') }}" class="btn btn-primary">
|
||||
<i class="fas fa-upload"></i> Upload Image
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Filter Form -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label for="tags" class="form-label">Filter by Tags</label>
|
||||
<input type="text" class="form-control" id="tags" name="tags"
|
||||
value="{{ request.args.get('tags', '') }}"
|
||||
placeholder="Enter tags separated by commas">
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-outline-primary me-2">
|
||||
<i class="fas fa-filter"></i> Filter
|
||||
</button>
|
||||
<a href="{{ url_for('images') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times"></i> Clear
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if images %}
|
||||
<!-- Images Grid -->
|
||||
<div class="row">
|
||||
{% for image in images %}
|
||||
<div class="col-md-6 col-lg-4 col-xl-3 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-img-top bg-light d-flex align-items-center justify-content-center" style="height: 200px;">
|
||||
<img src="{{ get_api_base_url() }}/api/v1/images/{{ image.id }}/download"
|
||||
alt="{{ image.filename }}"
|
||||
class="image-thumbnail"
|
||||
onerror="this.src='data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZGRkIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPkltYWdlIE5vdCBGb3VuZDwvdGV4dD48L3N2Zz4='">
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h6 class="card-title text-truncate" title="{{ image.filename }}">
|
||||
{{ image.filename }}
|
||||
</h6>
|
||||
<p class="card-text small text-muted">
|
||||
{{ image.description or 'No description' }}
|
||||
</p>
|
||||
<div class="small text-muted">
|
||||
<div><i class="fas fa-weight"></i> {{ (image.file_size / 1024 / 1024) | round(2) }} MB</div>
|
||||
<div><i class="fas fa-calendar"></i> {{ image.upload_date.strftime('%Y-%m-%d') if image.upload_date else 'Unknown' }}</div>
|
||||
{% if image.tags %}
|
||||
<div class="mt-2">
|
||||
{% for tag in image.tags %}
|
||||
<span class="badge bg-secondary me-1">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if image.has_embedding %}
|
||||
<div class="mt-1">
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-brain"></i> AI Ready
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="btn-group w-100" role="group">
|
||||
<a href="{{ url_for('view_image', image_id=image.id) }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-eye"></i> View
|
||||
</a>
|
||||
<a href="{{ url_for('edit_image', image_id=image.id) }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
onclick="confirmDelete('{{ image.filename }}', '{{ url_for('delete_image', image_id=image.id) }}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total > limit %}
|
||||
<nav aria-label="Image pagination">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% set total_pages = (total / limit) | round(0, 'ceil') | int %}
|
||||
{% set current_page = page %}
|
||||
|
||||
<!-- Previous Page -->
|
||||
{% if current_page > 1 %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('images', page=current_page-1, tags=request.args.get('tags', '')) }}">
|
||||
<i class="fas fa-chevron-left"></i> Previous
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<!-- Page Numbers -->
|
||||
{% for page_num in range(1, total_pages + 1) %}
|
||||
{% if page_num == current_page %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_num }}</span>
|
||||
</li>
|
||||
{% elif page_num <= 3 or page_num > total_pages - 3 or (page_num >= current_page - 1 and page_num <= current_page + 1) %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('images', page=page_num, tags=request.args.get('tags', '')) }}">{{ page_num }}</a>
|
||||
</li>
|
||||
{% elif page_num == 4 or page_num == total_pages - 3 %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Next Page -->
|
||||
{% if current_page < total_pages %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('images', page=current_page+1, tags=request.args.get('tags', '')) }}">
|
||||
Next <i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="text-center text-muted">
|
||||
Showing {{ ((page - 1) * limit + 1) }} to {{ [page * limit, total] | min }} of {{ total }} images
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-images fa-4x text-muted mb-3"></i>
|
||||
<h4 class="text-muted">No Images Found</h4>
|
||||
<p class="text-muted">Upload your first image to get started.</p>
|
||||
<a href="{{ url_for('upload_image') }}" class="btn btn-primary">
|
||||
<i class="fas fa-upload"></i> Upload First Image
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-exclamation-triangle text-warning"></i> Confirm Delete
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the image "<span id="deleteImageName"></span>"?</p>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Warning:</strong> This action cannot be undone. The image will be permanently removed from storage.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times"></i> Cancel
|
||||
</button>
|
||||
<form id="deleteForm" method="POST" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash"></i> Delete Image
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function confirmDelete(imageName, deleteUrl) {
|
||||
document.getElementById('deleteImageName').textContent = imageName;
|
||||
document.getElementById('deleteForm').action = deleteUrl;
|
||||
new bootstrap.Modal(document.getElementById('deleteModal')).show();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,141 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Home - SEREACT Web Client{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="jumbotron bg-primary text-white p-5 rounded mb-4">
|
||||
<h1 class="display-4">
|
||||
<i class="fas fa-images"></i> SEREACT Web Client
|
||||
</h1>
|
||||
<p class="lead">Secure Image Management with AI-Powered Search</p>
|
||||
<hr class="my-4">
|
||||
<p>Upload, organize, and search your images using advanced AI embeddings and vector similarity.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-lg-3 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-upload fa-3x text-primary mb-3"></i>
|
||||
<h5 class="card-title">Upload Images</h5>
|
||||
<p class="card-text">Upload and organize your images with metadata and tags.</p>
|
||||
<a href="{{ url_for('upload_image') }}" class="btn btn-primary">
|
||||
<i class="fas fa-upload"></i> Upload
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-lg-3 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-search fa-3x text-success mb-3"></i>
|
||||
<h5 class="card-title">Search Images</h5>
|
||||
<p class="card-text">Find images using AI-powered semantic search capabilities.</p>
|
||||
<a href="{{ url_for('search') }}" class="btn btn-success">
|
||||
<i class="fas fa-search"></i> Search
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-lg-3 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-images fa-3x text-info mb-3"></i>
|
||||
<h5 class="card-title">Browse Images</h5>
|
||||
<p class="card-text">View and manage all your uploaded images in one place.</p>
|
||||
<a href="{{ url_for('images') }}" class="btn btn-info">
|
||||
<i class="fas fa-images"></i> Browse
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-lg-3 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<i class="fas fa-cog fa-3x text-warning mb-3"></i>
|
||||
<h5 class="card-title">Management</h5>
|
||||
<p class="card-text">Manage teams, users, and API keys for your organization.</p>
|
||||
<div class="btn-group-vertical w-100">
|
||||
<a href="{{ url_for('teams') }}" class="btn btn-outline-warning btn-sm mb-1">
|
||||
<i class="fas fa-users"></i> Teams
|
||||
</a>
|
||||
<a href="{{ url_for('users') }}" class="btn btn-outline-warning btn-sm mb-1">
|
||||
<i class="fas fa-user"></i> Users
|
||||
</a>
|
||||
<a href="{{ url_for('api_keys') }}" class="btn btn-outline-warning btn-sm">
|
||||
<i class="fas fa-key"></i> API Keys
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-info-circle"></i> Getting Started
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-rocket"></i> First Time Setup</h6>
|
||||
<ol>
|
||||
<li>Configure your API endpoint in <a href="{{ url_for('config') }}">Settings</a></li>
|
||||
<li>Use <a href="{{ url_for('bootstrap') }}">Bootstrap</a> to create initial team and admin user</li>
|
||||
<li>Start uploading and searching images!</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-key"></i> API Configuration</h6>
|
||||
<p>Make sure to:</p>
|
||||
<ul>
|
||||
<li>Set the correct API base URL</li>
|
||||
<li>Have a valid API key</li>
|
||||
<li>Ensure your SEREACT API is running</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-line"></i> Features
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<h6><i class="fas fa-brain text-primary"></i> AI-Powered Search</h6>
|
||||
<p class="small">Search images using natural language queries powered by Google Cloud Vision API embeddings.</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h6><i class="fas fa-shield-alt text-success"></i> Secure Storage</h6>
|
||||
<p class="small">Images are securely stored in Google Cloud Storage with team-based access control.</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h6><i class="fas fa-tags text-info"></i> Rich Metadata</h6>
|
||||
<p class="small">Organize images with descriptions, tags, and automatic metadata extraction.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,264 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Search Images - SEREACT Web Client{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center mb-4">
|
||||
<div class="col-md-10">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-search"></i> AI-Powered Image Search
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label for="query" class="form-label">
|
||||
<i class="fas fa-brain"></i> Search Query
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-lg" id="query" name="query"
|
||||
value="{{ query }}" placeholder="Describe what you're looking for..." required>
|
||||
<div class="form-text">
|
||||
Use natural language to describe the image content you're looking for
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||
<i class="fas fa-search"></i> Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options -->
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#advancedOptions">
|
||||
<i class="fas fa-cog"></i> Advanced Options
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="collapse mt-3" id="advancedOptions">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="limit" class="form-label">Results Limit</label>
|
||||
<select class="form-select" id="limit" name="limit">
|
||||
<option value="10" {{ 'selected' if request.form.get('limit') == '10' else '' }}>10 results</option>
|
||||
<option value="20" {{ 'selected' if request.form.get('limit') == '20' else '' }}>20 results</option>
|
||||
<option value="50" {{ 'selected' if request.form.get('limit') == '50' else '' }}>50 results</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="threshold" class="form-label">Similarity Threshold</label>
|
||||
<select class="form-select" id="threshold" name="threshold">
|
||||
<option value="0.5" {{ 'selected' if request.form.get('threshold') == '0.5' else '' }}>0.5 (More results)</option>
|
||||
<option value="0.7" {{ 'selected' if request.form.get('threshold') == '0.7' or not request.form.get('threshold') else '' }}>0.7 (Balanced)</option>
|
||||
<option value="0.8" {{ 'selected' if request.form.get('threshold') == '0.8' else '' }}>0.8 (More precise)</option>
|
||||
<option value="0.9" {{ 'selected' if request.form.get('threshold') == '0.9' else '' }}>0.9 (Very precise)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="tags" class="form-label">Filter by Tags</label>
|
||||
<input type="text" class="form-control" id="tags" name="tags"
|
||||
value="{{ request.form.get('tags', '') }}"
|
||||
placeholder="tag1, tag2, tag3">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if query %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4>
|
||||
<i class="fas fa-search-plus"></i> Search Results for "{{ query }}"
|
||||
{% if total is defined %}
|
||||
<span class="badge bg-primary">{{ total }} found</span>
|
||||
{% endif %}
|
||||
</h4>
|
||||
<a href="{{ url_for('search') }}" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-times"></i> Clear Search
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if results %}
|
||||
<div class="row">
|
||||
{% for image in results %}
|
||||
<div class="col-md-6 col-lg-4 col-xl-3 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-img-top bg-light d-flex align-items-center justify-content-center" style="height: 200px;">
|
||||
<img src="{{ get_api_base_url() }}/api/v1/images/{{ image.id }}/download"
|
||||
alt="{{ image.filename }}"
|
||||
class="search-result-image"
|
||||
onerror="this.src='data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTUwIiBoZWlnaHQ9IjE1MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZGRkIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxMiIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPkltYWdlIE5vdCBGb3VuZDwvdGV4dD48L3N2Zz4='">
|
||||
</div>
|
||||
|
||||
<!-- Similarity Score Bar -->
|
||||
{% if image.similarity_score %}
|
||||
<div class="px-3 pt-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<small class="text-muted">Similarity</small>
|
||||
<small class="text-muted">{{ (image.similarity_score * 100) | round(1) }}%</small>
|
||||
</div>
|
||||
<div class="progress" style="height: 4px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width: {{ (image.similarity_score * 100) | round(1) }}%;
|
||||
background-color: {% if image.similarity_score > 0.8 %}#28a745{% elif image.similarity_score > 0.6 %}#ffc107{% else %}#dc3545{% endif %};">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card-body">
|
||||
<h6 class="card-title text-truncate" title="{{ image.filename }}">
|
||||
{{ image.filename }}
|
||||
</h6>
|
||||
<p class="card-text small text-muted">
|
||||
{{ image.description or 'No description' }}
|
||||
</p>
|
||||
<div class="small text-muted">
|
||||
<div><i class="fas fa-weight"></i> {{ (image.file_size / 1024 / 1024) | round(2) }} MB</div>
|
||||
<div><i class="fas fa-calendar"></i> {{ image.upload_date.strftime('%Y-%m-%d') if image.upload_date else 'Unknown' }}</div>
|
||||
{% if image.tags %}
|
||||
<div class="mt-2">
|
||||
{% for tag in image.tags %}
|
||||
<span class="badge bg-secondary me-1">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="btn-group w-100" role="group">
|
||||
<a href="{{ url_for('view_image', image_id=image.id) }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-eye"></i> View
|
||||
</a>
|
||||
<a href="{{ get_api_base_url() }}/api/v1/images/{{ image.id }}/download"
|
||||
class="btn btn-outline-secondary btn-sm" target="_blank">
|
||||
<i class="fas fa-download"></i> Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-search fa-4x text-muted mb-3"></i>
|
||||
<h4 class="text-muted">No Results Found</h4>
|
||||
<p class="text-muted">Try adjusting your search query or reducing the similarity threshold.</p>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-outline-primary" onclick="document.getElementById('threshold').value='0.5'; document.querySelector('form').submit();">
|
||||
<i class="fas fa-adjust"></i> Search with Lower Threshold
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- Search Examples -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-lightbulb"></i> Search Examples
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-3">Try these example searches to see how AI-powered semantic search works:</p>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-image text-primary"></i> Visual Content</h6>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="searchExample('sunset over mountains')">sunset over mountains</button>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="searchExample('people smiling')">people smiling</button>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="searchExample('red car')">red car</button>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="searchExample('city skyline at night')">city skyline at night</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-palette text-success"></i> Colors & Moods</h6>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button class="btn btn-outline-success btn-sm" onclick="searchExample('bright colorful flowers')">bright colorful flowers</button>
|
||||
<button class="btn btn-outline-success btn-sm" onclick="searchExample('dark moody atmosphere')">dark moody atmosphere</button>
|
||||
<button class="btn btn-outline-success btn-sm" onclick="searchExample('blue ocean waves')">blue ocean waves</button>
|
||||
<button class="btn btn-outline-success btn-sm" onclick="searchExample('warm golden light')">warm golden light</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-map-marker-alt text-info"></i> Places & Objects</h6>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button class="btn btn-outline-info btn-sm" onclick="searchExample('forest with tall trees')">forest with tall trees</button>
|
||||
<button class="btn btn-outline-info btn-sm" onclick="searchExample('modern building architecture')">modern building architecture</button>
|
||||
<button class="btn btn-outline-info btn-sm" onclick="searchExample('food on a plate')">food on a plate</button>
|
||||
<button class="btn btn-outline-info btn-sm" onclick="searchExample('animal in nature')">animal in nature</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-running text-warning"></i> Activities & Emotions</h6>
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button class="btn btn-outline-warning btn-sm" onclick="searchExample('people playing sports')">people playing sports</button>
|
||||
<button class="btn btn-outline-warning btn-sm" onclick="searchExample('peaceful meditation')">peaceful meditation</button>
|
||||
<button class="btn btn-outline-warning btn-sm" onclick="searchExample('celebration party')">celebration party</button>
|
||||
<button class="btn btn-outline-warning btn-sm" onclick="searchExample('work meeting')">work meeting</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-info-circle"></i> How AI Search Works
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<h6><i class="fas fa-brain text-primary"></i> Semantic Understanding</h6>
|
||||
<p class="small">The AI understands the meaning behind your words, not just exact matches. It can find images based on concepts, emotions, and visual elements.</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h6><i class="fas fa-vector-square text-success"></i> Vector Similarity</h6>
|
||||
<p class="small">Images are converted to high-dimensional vectors that capture visual features. Search finds images with similar vector representations.</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h6><i class="fas fa-sliders-h text-info"></i> Adjustable Precision</h6>
|
||||
<p class="small">Use the similarity threshold to control how strict the matching is. Lower values return more results, higher values are more precise.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function searchExample(query) {
|
||||
document.getElementById('query').value = query;
|
||||
document.querySelector('form').submit();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,145 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ action }} Team - SEREACT Web Client{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-users"></i> {{ action }} Team
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">
|
||||
<i class="fas fa-tag"></i> Team Name *
|
||||
</label>
|
||||
<input type="text" class="form-control" id="name" name="name"
|
||||
value="{{ team.name if team else '' }}"
|
||||
placeholder="Enter team name" required>
|
||||
<div class="form-text">
|
||||
A unique name for this team
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">
|
||||
<i class="fas fa-align-left"></i> Description
|
||||
</label>
|
||||
<textarea class="form-control" id="description" name="description"
|
||||
rows="3" placeholder="Enter team description">{{ team.description if team else '' }}</textarea>
|
||||
<div class="form-text">
|
||||
Optional description of the team's purpose or role
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if team %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="fas fa-info-circle"></i> Team Information
|
||||
</label>
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-calendar"></i> Created:
|
||||
{{ team.created_at.strftime('%Y-%m-%d %H:%M') if team.created_at else 'Unknown' }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-edit"></i> Updated:
|
||||
{{ team.updated_at.strftime('%Y-%m-%d %H:%M') if team.updated_at else 'Unknown' }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-fingerprint"></i> ID: {{ team.id }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{{ url_for('teams') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Teams
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> {{ action }} Team
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if action == 'Edit' %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-exclamation-triangle"></i> Danger Zone
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
Deleting a team is permanent and cannot be undone. All associated data may be affected.
|
||||
</p>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
onclick="confirmDelete('{{ team.name }}', '{{ url_for('delete_team', team_id=team.id) }}')">
|
||||
<i class="fas fa-trash"></i> Delete Team
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-exclamation-triangle text-warning"></i> Confirm Delete
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the team "<span id="deleteTeamName"></span>"?</p>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Warning:</strong> This action cannot be undone.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times"></i> Cancel
|
||||
</button>
|
||||
<form id="deleteForm" method="POST" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash"></i> Delete Team
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if action == 'Edit' %}
|
||||
<script>
|
||||
function confirmDelete(teamName, deleteUrl) {
|
||||
document.getElementById('deleteTeamName').textContent = teamName;
|
||||
document.getElementById('deleteForm').action = deleteUrl;
|
||||
new bootstrap.Modal(document.getElementById('deleteModal')).show();
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@ -1,96 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Teams - SEREACT Web Client{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-users"></i> Teams</h2>
|
||||
<a href="{{ url_for('create_team') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Create Team
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if teams %}
|
||||
<div class="row">
|
||||
{% for team in teams %}
|
||||
<div class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<i class="fas fa-users text-primary"></i> {{ team.name }}
|
||||
</h5>
|
||||
<p class="card-text">{{ team.description or 'No description provided' }}</p>
|
||||
<div class="text-muted small">
|
||||
<div><i class="fas fa-calendar"></i> Created: {{ team.created_at.strftime('%Y-%m-%d %H:%M') if team.created_at else 'Unknown' }}</div>
|
||||
{% if team.updated_at and team.updated_at != team.created_at %}
|
||||
<div><i class="fas fa-edit"></i> Updated: {{ team.updated_at.strftime('%Y-%m-%d %H:%M') }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="btn-group w-100" role="group">
|
||||
<a href="{{ url_for('edit_team', team_id=team.id) }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
onclick="confirmDelete('{{ team.name }}', '{{ url_for('delete_team', team_id=team.id) }}')">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-users fa-4x text-muted mb-3"></i>
|
||||
<h4 class="text-muted">No Teams Found</h4>
|
||||
<p class="text-muted">Create your first team to get started.</p>
|
||||
<a href="{{ url_for('create_team') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Create First Team
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-exclamation-triangle text-warning"></i> Confirm Delete
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the team "<span id="deleteTeamName"></span>"?</p>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Warning:</strong> This action cannot be undone. All associated data may be affected.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times"></i> Cancel
|
||||
</button>
|
||||
<form id="deleteForm" method="POST" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash"></i> Delete Team
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function confirmDelete(teamName, deleteUrl) {
|
||||
document.getElementById('deleteTeamName').textContent = teamName;
|
||||
document.getElementById('deleteForm').action = deleteUrl;
|
||||
new bootstrap.Modal(document.getElementById('deleteModal')).show();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,182 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ action }} User - SEREACT Web Client{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-user"></i> {{ action }} User
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">
|
||||
<i class="fas fa-user"></i> Full Name *
|
||||
</label>
|
||||
<input type="text" class="form-control" id="name" name="name"
|
||||
value="{{ user.name if user else '' }}"
|
||||
placeholder="Enter full name" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">
|
||||
<i class="fas fa-envelope"></i> Email Address *
|
||||
</label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
value="{{ user.email if user else '' }}"
|
||||
placeholder="Enter email address" required>
|
||||
</div>
|
||||
|
||||
{% if action == 'Create' %}
|
||||
<div class="mb-3">
|
||||
<label for="team_id" class="form-label">
|
||||
<i class="fas fa-users"></i> Team
|
||||
</label>
|
||||
<select class="form-select" id="team_id" name="team_id">
|
||||
<option value="">Select a team (optional)</option>
|
||||
{% for team in teams %}
|
||||
<option value="{{ team.id }}">{{ team.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Leave empty to assign to current user's team
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="is_admin" name="is_admin"
|
||||
{{ 'checked' if user and user.is_admin else '' }}>
|
||||
<label class="form-check-label" for="is_admin">
|
||||
<i class="fas fa-crown text-warning"></i> Administrator
|
||||
</label>
|
||||
<div class="form-text">
|
||||
Administrators have full access to all system features
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="fas fa-info-circle"></i> User Information
|
||||
</label>
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-fingerprint"></i> ID: {{ user.id }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-users"></i> Team ID: {{ user.team_id }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-calendar"></i> Created:
|
||||
{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'Unknown' }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-circle {{ 'text-success' if user.is_active else 'text-danger' }}"></i>
|
||||
{{ 'Active' if user.is_active else 'Inactive' }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{{ url_for('users') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Users
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> {{ action }} User
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if action == 'Edit' %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h6 class="mb-0">
|
||||
<i class="fas fa-exclamation-triangle"></i> Danger Zone
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
Deleting a user is permanent and cannot be undone. All user data and associated content will be affected.
|
||||
</p>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
onclick="confirmDelete('{{ user.name }}', '{{ user.email }}', '{{ url_for('delete_user', user_id=user.id) }}')">
|
||||
<i class="fas fa-trash"></i> Delete User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-exclamation-triangle text-warning"></i> Confirm Delete
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the user:</p>
|
||||
<div class="alert alert-info">
|
||||
<strong><span id="deleteUserName"></span></strong><br>
|
||||
<small><span id="deleteUserEmail"></span></small>
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Warning:</strong> This action cannot be undone.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times"></i> Cancel
|
||||
</button>
|
||||
<form id="deleteForm" method="POST" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash"></i> Delete User
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if action == 'Edit' %}
|
||||
<script>
|
||||
function confirmDelete(userName, userEmail, deleteUrl) {
|
||||
document.getElementById('deleteUserName').textContent = userName;
|
||||
document.getElementById('deleteUserEmail').textContent = userEmail;
|
||||
document.getElementById('deleteForm').action = deleteUrl;
|
||||
new bootstrap.Modal(document.getElementById('deleteModal')).show();
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@ -1,112 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Users - SEREACT Web Client{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-user"></i> Users</h2>
|
||||
<a href="{{ url_for('create_user') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Create User
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if users %}
|
||||
<div class="row">
|
||||
{% for user in users %}
|
||||
<div class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<i class="fas fa-user text-primary"></i> {{ user.name }}
|
||||
{% if user.is_admin %}
|
||||
<span class="badge bg-warning text-dark ms-2">
|
||||
<i class="fas fa-crown"></i> Admin
|
||||
</span>
|
||||
{% endif %}
|
||||
</h5>
|
||||
<p class="card-text">
|
||||
<i class="fas fa-envelope"></i> {{ user.email }}
|
||||
</p>
|
||||
<p class="card-text">
|
||||
<i class="fas fa-users"></i> Team ID: {{ user.team_id }}
|
||||
</p>
|
||||
<div class="text-muted small">
|
||||
<div>
|
||||
<i class="fas fa-circle {{ 'text-success' if user.is_active else 'text-danger' }}"></i>
|
||||
{{ 'Active' if user.is_active else 'Inactive' }}
|
||||
</div>
|
||||
<div><i class="fas fa-calendar"></i> Created: {{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'Unknown' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="btn-group w-100" role="group">
|
||||
<a href="{{ url_for('edit_user', user_id=user.id) }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
onclick="confirmDelete('{{ user.name }}', '{{ user.email }}', '{{ url_for('delete_user', user_id=user.id) }}')">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-user fa-4x text-muted mb-3"></i>
|
||||
<h4 class="text-muted">No Users Found</h4>
|
||||
<p class="text-muted">Create your first user to get started.</p>
|
||||
<a href="{{ url_for('create_user') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Create First User
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-exclamation-triangle text-warning"></i> Confirm Delete
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the user:</p>
|
||||
<div class="alert alert-info">
|
||||
<strong><span id="deleteUserName"></span></strong><br>
|
||||
<small><span id="deleteUserEmail"></span></small>
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Warning:</strong> This action cannot be undone. All user data and associated content may be affected.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times"></i> Cancel
|
||||
</button>
|
||||
<form id="deleteForm" method="POST" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash"></i> Delete User
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function confirmDelete(userName, userEmail, deleteUrl) {
|
||||
document.getElementById('deleteUserName').textContent = userName;
|
||||
document.getElementById('deleteUserEmail').textContent = userEmail;
|
||||
document.getElementById('deleteForm').action = deleteUrl;
|
||||
new bootstrap.Modal(document.getElementById('deleteModal')).show();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -82,6 +82,60 @@ PROJECT_ID=my-project-id REGION=us-west1 IMAGE_TAG=v1.0.0 ./scripts/deploy-to-cl
|
||||
- `BUILD`: Set to "true" to build the image before deployment (default: "false")
|
||||
- `PUSH`: Set to "true" to push the image before deployment (default: "false")
|
||||
|
||||
### Frontend Client Script (`client.sh`)
|
||||
|
||||
Manages the SeReact frontend client development, building, and deployment.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Start development server
|
||||
./scripts/client.sh dev
|
||||
# or
|
||||
./scripts/client.sh serve
|
||||
|
||||
# Build production files
|
||||
./scripts/client.sh build
|
||||
|
||||
# Deploy to static hosting
|
||||
DEPLOY_TARGET=netlify ./scripts/client.sh deploy
|
||||
|
||||
# Clean build artifacts
|
||||
./scripts/client.sh clean
|
||||
|
||||
# Install dependencies
|
||||
./scripts/client.sh install
|
||||
|
||||
# Check code quality
|
||||
./scripts/client.sh lint
|
||||
|
||||
# Run tests
|
||||
./scripts/client.sh test
|
||||
|
||||
# Show help
|
||||
./scripts/client.sh help
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
- `PORT`: Development server port (default: 8080)
|
||||
- `HOST`: Development server host (default: localhost)
|
||||
- `DEPLOY_TARGET`: Deployment target - netlify, vercel, s3, github, or manual (default: manual)
|
||||
- `S3_BUCKET`: S3 bucket name (required for S3 deployment)
|
||||
|
||||
**Features:**
|
||||
- **Development Server**: Starts a local HTTP server with CORS headers for frontend development
|
||||
- **Production Build**: Creates optimized production files in `dist/` directory
|
||||
- **Multiple Deployment Options**: Supports Netlify, Vercel, AWS S3, GitHub Pages, and manual deployment
|
||||
- **Code Quality**: Integrates with ESLint, Stylelint, and HTML Tidy (if installed)
|
||||
- **Dependency Management**: Handles Python virtual environment setup
|
||||
- **Clean Build**: Removes build artifacts and cache files
|
||||
|
||||
**Deployment Targets:**
|
||||
- `netlify`: Deploy using Netlify CLI
|
||||
- `vercel`: Deploy using Vercel CLI
|
||||
- `s3`: Deploy to AWS S3 bucket (requires AWS CLI and S3_BUCKET env var)
|
||||
- `github`: Shows instructions for GitHub Pages deployment
|
||||
- `manual`: Shows manual deployment instructions
|
||||
|
||||
## Example Workflows
|
||||
|
||||
### Basic workflow:
|
||||
@ -99,6 +153,21 @@ DEPLOY_TO_CLOUD_RUN=true PROJECT_ID=my-project-id IMAGE_TAG=v1.0.0 ./scripts/dep
|
||||
PROJECT_ID=my-project-id BUILD=true PUSH=true ./scripts/deploy-to-cloud-run.sh
|
||||
```
|
||||
|
||||
### Frontend Development Workflow:
|
||||
```bash
|
||||
# Install dependencies
|
||||
./scripts/client.sh install
|
||||
|
||||
# Start development server
|
||||
./scripts/client.sh dev
|
||||
|
||||
# Build for production
|
||||
./scripts/client.sh build
|
||||
|
||||
# Deploy to Netlify
|
||||
DEPLOY_TARGET=netlify ./scripts/client.sh deploy
|
||||
```
|
||||
|
||||
# Scripts Documentation
|
||||
|
||||
This directory contains utility scripts for the SEREACT application.
|
||||
|
||||
322
scripts/client.sh
Normal file
322
scripts/client.sh
Normal file
@ -0,0 +1,322 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
PURPLE='\033[0;35m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
CLIENT_DIR="client"
|
||||
DIST_DIR="dist"
|
||||
PORT=${PORT:-8080}
|
||||
HOST=${HOST:-localhost}
|
||||
|
||||
# Print colored output
|
||||
print_color() {
|
||||
local color=$1
|
||||
local message=$2
|
||||
echo -e "${color}${message}${NC}"
|
||||
}
|
||||
|
||||
print_header() {
|
||||
echo
|
||||
print_color $CYAN "🚀 SeReact Frontend Client Manager"
|
||||
echo
|
||||
}
|
||||
|
||||
print_usage() {
|
||||
print_header
|
||||
echo "Usage: $0 [COMMAND]"
|
||||
echo
|
||||
echo "Commands:"
|
||||
echo " dev, serve Start development server"
|
||||
echo " build Build production files"
|
||||
echo " deploy Deploy to static hosting"
|
||||
echo " clean Clean build artifacts"
|
||||
echo " install Install dependencies"
|
||||
echo " lint Check code quality"
|
||||
echo " test Run tests (if available)"
|
||||
echo " help Show this help message"
|
||||
echo
|
||||
echo "Environment Variables:"
|
||||
echo " PORT Development server port (default: 8080)"
|
||||
echo " HOST Development server host (default: localhost)"
|
||||
echo " DEPLOY_TARGET Deployment target (netlify, vercel, s3, github)"
|
||||
echo
|
||||
}
|
||||
|
||||
# Check if we're in the right directory
|
||||
check_directory() {
|
||||
if [ ! -d "$CLIENT_DIR" ]; then
|
||||
print_color $RED "❌ Error: Client directory not found!"
|
||||
print_color $YELLOW " Make sure you're running this from the project root."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Start development server
|
||||
start_dev_server() {
|
||||
print_color $GREEN "🔧 Starting development server..."
|
||||
|
||||
cd "$CLIENT_DIR"
|
||||
|
||||
# Check if index.html exists
|
||||
if [ ! -f "index.html" ]; then
|
||||
print_color $RED "❌ Error: index.html not found in client directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Python is available
|
||||
if command -v python3 &> /dev/null; then
|
||||
print_color $BLUE "🐍 Using Python development server"
|
||||
python3 serve.py
|
||||
elif command -v python &> /dev/null; then
|
||||
print_color $BLUE "🐍 Using Python development server"
|
||||
python serve.py
|
||||
else
|
||||
print_color $YELLOW "⚠️ Python not found, using basic HTTP server"
|
||||
# Fallback to basic HTTP server
|
||||
if command -v npx &> /dev/null; then
|
||||
npx http-server . -p $PORT --cors
|
||||
else
|
||||
print_color $RED "❌ No suitable HTTP server found"
|
||||
print_color $YELLOW " Please install Python or Node.js"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Build production files
|
||||
build_production() {
|
||||
print_color $GREEN "🏗️ Building production files..."
|
||||
|
||||
# Create dist directory
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
# Copy client files to dist
|
||||
print_color $BLUE "📁 Copying files to $DIST_DIR..."
|
||||
cp -r "$CLIENT_DIR"/* "$DIST_DIR/"
|
||||
|
||||
# Remove development-only files
|
||||
if [ -f "$DIST_DIR/serve.py" ]; then
|
||||
rm "$DIST_DIR/serve.py"
|
||||
print_color $YELLOW "🗑️ Removed serve.py (development only)"
|
||||
fi
|
||||
|
||||
if [ -d "$DIST_DIR/venv" ]; then
|
||||
rm -rf "$DIST_DIR/venv"
|
||||
print_color $YELLOW "🗑️ Removed venv directory (development only)"
|
||||
fi
|
||||
|
||||
if [ -f "$DIST_DIR/requirements.txt" ]; then
|
||||
rm "$DIST_DIR/requirements.txt"
|
||||
print_color $YELLOW "🗑️ Removed requirements.txt (development only)"
|
||||
fi
|
||||
|
||||
# Minify files if tools are available
|
||||
if command -v terser &> /dev/null; then
|
||||
print_color $BLUE "🗜️ Minifying JavaScript files..."
|
||||
find "$DIST_DIR/js" -name "*.js" -exec terser {} -o {} \;
|
||||
fi
|
||||
|
||||
if command -v csso &> /dev/null; then
|
||||
print_color $BLUE "🗜️ Minifying CSS files..."
|
||||
find "$DIST_DIR" -name "*.css" -exec csso {} --output {} \;
|
||||
fi
|
||||
|
||||
print_color $GREEN "✅ Build completed successfully!"
|
||||
print_color $CYAN "📦 Production files are in: $DIST_DIR/"
|
||||
}
|
||||
|
||||
# Deploy to static hosting
|
||||
deploy_production() {
|
||||
local target=${DEPLOY_TARGET:-"manual"}
|
||||
|
||||
print_color $GREEN "🚀 Deploying to $target..."
|
||||
|
||||
# Build first
|
||||
build_production
|
||||
|
||||
case $target in
|
||||
"netlify")
|
||||
if command -v netlify &> /dev/null; then
|
||||
cd "$DIST_DIR"
|
||||
netlify deploy --prod --dir .
|
||||
else
|
||||
print_color $RED "❌ Netlify CLI not found"
|
||||
print_color $YELLOW " Install with: npm install -g netlify-cli"
|
||||
fi
|
||||
;;
|
||||
"vercel")
|
||||
if command -v vercel &> /dev/null; then
|
||||
cd "$DIST_DIR"
|
||||
vercel --prod
|
||||
else
|
||||
print_color $RED "❌ Vercel CLI not found"
|
||||
print_color $YELLOW " Install with: npm install -g vercel"
|
||||
fi
|
||||
;;
|
||||
"s3")
|
||||
if command -v aws &> /dev/null; then
|
||||
if [ -z "$S3_BUCKET" ]; then
|
||||
print_color $RED "❌ S3_BUCKET environment variable not set"
|
||||
exit 1
|
||||
fi
|
||||
aws s3 sync "$DIST_DIR/" "s3://$S3_BUCKET/" --delete
|
||||
print_color $GREEN "✅ Deployed to S3 bucket: $S3_BUCKET"
|
||||
else
|
||||
print_color $RED "❌ AWS CLI not found"
|
||||
print_color $YELLOW " Install AWS CLI first"
|
||||
fi
|
||||
;;
|
||||
"github")
|
||||
print_color $BLUE "📋 GitHub Pages deployment instructions:"
|
||||
echo "1. Push the contents of $DIST_DIR/ to your gh-pages branch"
|
||||
echo "2. Enable GitHub Pages in repository settings"
|
||||
echo "3. Set source to gh-pages branch"
|
||||
;;
|
||||
*)
|
||||
print_color $BLUE "📋 Manual deployment instructions:"
|
||||
echo "1. Upload the contents of $DIST_DIR/ to your web server"
|
||||
echo "2. Configure your server to serve index.html for all routes"
|
||||
echo "3. Ensure CORS is configured for API calls"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Clean build artifacts
|
||||
clean_build() {
|
||||
print_color $YELLOW "🧹 Cleaning build artifacts..."
|
||||
|
||||
if [ -d "$DIST_DIR" ]; then
|
||||
rm -rf "$DIST_DIR"
|
||||
print_color $GREEN "✅ Removed $DIST_DIR directory"
|
||||
fi
|
||||
|
||||
# Clean any cache directories
|
||||
if [ -d "$CLIENT_DIR/__pycache__" ]; then
|
||||
rm -rf "$CLIENT_DIR/__pycache__"
|
||||
print_color $GREEN "✅ Removed Python cache"
|
||||
fi
|
||||
|
||||
print_color $GREEN "✅ Clean completed!"
|
||||
}
|
||||
|
||||
# Install dependencies
|
||||
install_dependencies() {
|
||||
print_color $GREEN "📦 Installing dependencies..."
|
||||
|
||||
cd "$CLIENT_DIR"
|
||||
|
||||
# Check if virtual environment exists
|
||||
if [ ! -d "venv" ]; then
|
||||
print_color $BLUE "🐍 Creating Python virtual environment..."
|
||||
python3 -m venv venv
|
||||
fi
|
||||
|
||||
# Activate virtual environment
|
||||
if [ -f "venv/Scripts/activate" ]; then
|
||||
# Windows with Git Bash
|
||||
source venv/Scripts/activate
|
||||
else
|
||||
# Linux/macOS
|
||||
source venv/bin/activate
|
||||
fi
|
||||
|
||||
# Install Python dependencies
|
||||
if [ -f "requirements.txt" ]; then
|
||||
print_color $BLUE "📋 Installing Python packages..."
|
||||
pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
print_color $GREEN "✅ Dependencies installed!"
|
||||
}
|
||||
|
||||
# Lint code
|
||||
lint_code() {
|
||||
print_color $GREEN "🔍 Checking code quality..."
|
||||
|
||||
cd "$CLIENT_DIR"
|
||||
|
||||
# Check HTML
|
||||
if command -v tidy &> /dev/null; then
|
||||
print_color $BLUE "🔍 Checking HTML..."
|
||||
tidy -q -e index.html || true
|
||||
fi
|
||||
|
||||
# Check JavaScript
|
||||
if command -v eslint &> /dev/null; then
|
||||
print_color $BLUE "🔍 Checking JavaScript..."
|
||||
eslint js/*.js || true
|
||||
fi
|
||||
|
||||
# Check CSS
|
||||
if command -v stylelint &> /dev/null; then
|
||||
print_color $BLUE "🔍 Checking CSS..."
|
||||
stylelint *.css || true
|
||||
fi
|
||||
|
||||
print_color $GREEN "✅ Code quality check completed!"
|
||||
}
|
||||
|
||||
# Run tests
|
||||
run_tests() {
|
||||
print_color $GREEN "🧪 Running tests..."
|
||||
|
||||
cd "$CLIENT_DIR"
|
||||
|
||||
# Check if test files exist
|
||||
if [ -d "tests" ] || [ -f "test.html" ]; then
|
||||
print_color $BLUE "🧪 Running frontend tests..."
|
||||
# Add test runner here when tests are implemented
|
||||
print_color $YELLOW "⚠️ No test runner configured yet"
|
||||
else
|
||||
print_color $YELLOW "⚠️ No tests found"
|
||||
fi
|
||||
|
||||
print_color $GREEN "✅ Tests completed!"
|
||||
}
|
||||
|
||||
# Main script logic
|
||||
main() {
|
||||
case "${1:-help}" in
|
||||
"dev"|"serve")
|
||||
check_directory
|
||||
start_dev_server
|
||||
;;
|
||||
"build")
|
||||
check_directory
|
||||
build_production
|
||||
;;
|
||||
"deploy")
|
||||
check_directory
|
||||
deploy_production
|
||||
;;
|
||||
"clean")
|
||||
clean_build
|
||||
;;
|
||||
"install")
|
||||
check_directory
|
||||
install_dependencies
|
||||
;;
|
||||
"lint")
|
||||
check_directory
|
||||
lint_code
|
||||
;;
|
||||
"test")
|
||||
check_directory
|
||||
run_tests
|
||||
;;
|
||||
"help"|*)
|
||||
print_usage
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Run main function with all arguments
|
||||
main "$@"
|
||||
Loading…
x
Reference in New Issue
Block a user