client improvements

This commit is contained in:
johnpccd 2025-05-24 15:17:02 +02:00
parent 88905a2684
commit 98b6bd9313
32 changed files with 4094 additions and 2924 deletions

View File

@ -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.

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
});

View File

@ -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
View 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
View 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;
}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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
View 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 "$@"