client
This commit is contained in:
parent
046746c5b6
commit
1eea7967aa
208
client/README.md
Normal file
208
client/README.md
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
# SEREACT Web 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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- **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
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.8+
|
||||||
|
- Running SEREACT API server
|
||||||
|
- Network access to the SEREACT API
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Navigate to the client directory:
|
||||||
|
```bash
|
||||||
|
cd client
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create and activate a virtual environment:
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # Linux/macOS
|
||||||
|
venv\Scripts\activate # Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install dependencies:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
- 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
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
You can set these environment variables to configure default values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SEREACT_API_URL=http://localhost:8000
|
||||||
|
export SEREACT_API_KEY=your-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Storage
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
The client integrates with the following SEREACT API endpoints:
|
||||||
|
|
||||||
|
- `/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
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- **API Keys**: Store API keys securely and never share them
|
||||||
|
- **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
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Connection Errors**:
|
||||||
|
- Verify the API base URL is correct
|
||||||
|
- Ensure the SEREACT API server is running
|
||||||
|
- Check network connectivity
|
||||||
|
|
||||||
|
2. **Authentication Errors**:
|
||||||
|
- Verify your API key is valid and active
|
||||||
|
- Check if the API key has expired
|
||||||
|
- Ensure you have the necessary permissions
|
||||||
|
|
||||||
|
3. **Image Upload Issues**:
|
||||||
|
- Check file size (max 10MB)
|
||||||
|
- Verify file format is supported
|
||||||
|
- Ensure sufficient storage space
|
||||||
|
|
||||||
|
4. **Search Not Working**:
|
||||||
|
- Verify images have been processed (have embeddings)
|
||||||
|
- Check if the vector database is configured
|
||||||
|
- Try adjusting similarity thresholds
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- **"File size exceeds limit"**: Choose a smaller image file
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This web client is part of the SEREACT project and follows the same license terms.
|
||||||
423
client/app.py
Normal file
423
client/app.py
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
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)
|
||||||
3
client/requirements.txt
Normal file
3
client/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
Flask==2.3.3
|
||||||
|
requests==2.31.0
|
||||||
|
Werkzeug==2.3.7
|
||||||
111
client/templates/api_key_form.html
Normal file
111
client/templates/api_key_form.html
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
{% 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 %}
|
||||||
101
client/templates/api_keys.html
Normal file
101
client/templates/api_keys.html
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
{% 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 %}
|
||||||
116
client/templates/base.html
Normal file
116
client/templates/base.html
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
<!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>
|
||||||
140
client/templates/bootstrap.html
Normal file
140
client/templates/bootstrap.html
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
{% 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 %}
|
||||||
150
client/templates/config.html
Normal file
150
client/templates/config.html
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
{% 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 %}
|
||||||
246
client/templates/image_detail.html
Normal file
246
client/templates/image_detail.html
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
{% 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 %}
|
||||||
186
client/templates/image_edit.html
Normal file
186
client/templates/image_edit.html
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
{% 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 %}
|
||||||
168
client/templates/image_upload.html
Normal file
168
client/templates/image_upload.html
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
{% 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 %}
|
||||||
192
client/templates/images.html
Normal file
192
client/templates/images.html
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
{% 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 %}
|
||||||
141
client/templates/index.html
Normal file
141
client/templates/index.html
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
{% 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 %}
|
||||||
264
client/templates/search.html
Normal file
264
client/templates/search.html
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
{% 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 %}
|
||||||
145
client/templates/team_form.html
Normal file
145
client/templates/team_form.html
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
{% 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 %}
|
||||||
96
client/templates/teams.html
Normal file
96
client/templates/teams.html
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
{% 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 %}
|
||||||
182
client/templates/user_form.html
Normal file
182
client/templates/user_form.html
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
{% 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 %}
|
||||||
112
client/templates/users.html
Normal file
112
client/templates/users.html
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
{% 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 %}
|
||||||
Loading…
x
Reference in New Issue
Block a user