code hygiene
This commit is contained in:
@@ -37,8 +37,8 @@ app.config['TEMPLATES_AUTO_RELOAD'] = os.environ.get('TEMPLATES_AUTO_RELOAD')
|
|||||||
|
|
||||||
# Session configuration for "Remember Me" functionality
|
# Session configuration for "Remember Me" functionality
|
||||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=30)
|
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=30)
|
||||||
app.config['SESSION_COOKIE_SECURE'] = os.environ.get('SESSION_COOKIE_SECURE') # Set to True if using HTTPS
|
app.config['SESSION_COOKIE_SECURE'] = os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true' # Set to True if using HTTPS
|
||||||
app.config['SESSION_COOKIE_HTTPONLY'] = os.environ.get('SESSION_COOKIE_HTTPONLY')
|
app.config['SESSION_COOKIE_HTTPONLY'] = os.environ.get('SESSION_COOKIE_HTTPONLY', 'true').lower() == 'true'
|
||||||
app.config['SESSION_COOKIE_SAMESITE'] = os.environ.get('SESSION_COOKIE_SAMESITE')
|
app.config['SESSION_COOKIE_SAMESITE'] = os.environ.get('SESSION_COOKIE_SAMESITE')
|
||||||
|
|
||||||
# Photo Verification Configuration
|
# Photo Verification Configuration
|
||||||
@@ -110,8 +110,8 @@ def create_performance_indexes():
|
|||||||
print(f"❌ Error creating performance indexes: {e}")
|
print(f"❌ Error creating performance indexes: {e}")
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
logger_handler.log_database_error(
|
logger_handler.log_database_error(
|
||||||
error_type="index_creation_error",
|
'index_creation_error',
|
||||||
error_message=str(e),
|
e,
|
||||||
query="CREATE INDEX statements"
|
query="CREATE INDEX statements"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -4596,40 +4596,6 @@ def deactivate_qr_code(qr_id):
|
|||||||
'message': 'Error deactivating QR code. Please try again.'
|
'message': 'Error deactivating QR code. Please try again.'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
@app.route('/qr-codes/<int:qr_id>/toggle-status', methods=['POST'])
|
|
||||||
@admin_required
|
|
||||||
def toggle_qr_status_api(qr_id):
|
|
||||||
"""Toggle QR code active/inactive status - Enhanced JSON API"""
|
|
||||||
try:
|
|
||||||
qr_code = QRCode.query.get_or_404(qr_id)
|
|
||||||
qr_code.active_status = not qr_code.active_status
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
status_text = "activated" if qr_code.active_status else "deactivated"
|
|
||||||
|
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'new_status': qr_code.active_status,
|
|
||||||
'status_text': 'Active' if qr_code.active_status else 'Inactive',
|
|
||||||
'message': f'QR code "{qr_code.name}" has been {status_text} successfully!'
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
flash(f'QR code "{qr_code.name}" has been {status_text} successfully!', 'success')
|
|
||||||
return redirect(url_for('dashboard'))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
|
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
||||||
return jsonify({
|
|
||||||
'success': False,
|
|
||||||
'message': 'Error updating QR code status. Please try again.'
|
|
||||||
}), 500
|
|
||||||
else:
|
|
||||||
flash('Error updating QR code status. Please try again.', 'error')
|
|
||||||
return redirect(url_for('dashboard'))
|
|
||||||
|
|
||||||
@app.route('/attendance')
|
@app.route('/attendance')
|
||||||
@login_required
|
@login_required
|
||||||
def attendance_report():
|
def attendance_report():
|
||||||
@@ -6039,8 +6005,8 @@ def export_configuration():
|
|||||||
# Use your existing logger error method with correct parameters
|
# Use your existing logger error method with correct parameters
|
||||||
try:
|
try:
|
||||||
logger_handler.log_flask_error(
|
logger_handler.log_flask_error(
|
||||||
error_type="export_configuration_error",
|
'export_configuration_error',
|
||||||
error_message=str(e),
|
str(e),
|
||||||
stack_trace=traceback.format_exc()
|
stack_trace=traceback.format_exc()
|
||||||
)
|
)
|
||||||
except Exception as log_error:
|
except Exception as log_error:
|
||||||
@@ -6194,8 +6160,8 @@ def generate_excel_export():
|
|||||||
# Use your existing logger error method with correct parameters
|
# Use your existing logger error method with correct parameters
|
||||||
try:
|
try:
|
||||||
logger_handler.log_flask_error(
|
logger_handler.log_flask_error(
|
||||||
error_type="excel_export_error",
|
'excel_export_error',
|
||||||
error_message=str(e),
|
str(e),
|
||||||
stack_trace=traceback.format_exc()
|
stack_trace=traceback.format_exc()
|
||||||
)
|
)
|
||||||
except Exception as log_error:
|
except Exception as log_error:
|
||||||
@@ -6478,8 +6444,8 @@ def create_excel_export(selected_columns, column_names, filters):
|
|||||||
# Log error
|
# Log error
|
||||||
try:
|
try:
|
||||||
logger_handler.log_flask_error(
|
logger_handler.log_flask_error(
|
||||||
error_type="excel_export_error",
|
'excel_export_error',
|
||||||
error_message=str(e),
|
str(e),
|
||||||
stack_trace=traceback.format_exc()
|
stack_trace=traceback.format_exc()
|
||||||
)
|
)
|
||||||
except Exception as log_error:
|
except Exception as log_error:
|
||||||
@@ -6785,8 +6751,8 @@ def create_excel_export_ordered(selected_columns, column_names, filters):
|
|||||||
# Log error
|
# Log error
|
||||||
try:
|
try:
|
||||||
logger_handler.log_flask_error(
|
logger_handler.log_flask_error(
|
||||||
error_type="excel_export_ordered_error",
|
'excel_export_ordered_error',
|
||||||
error_message=str(e),
|
str(e),
|
||||||
stack_trace=traceback.format_exc()
|
stack_trace=traceback.format_exc()
|
||||||
)
|
)
|
||||||
except Exception as log_error:
|
except Exception as log_error:
|
||||||
@@ -6932,8 +6898,8 @@ def payroll_dashboard():
|
|||||||
print(f"❌ Traceback: {traceback.format_exc()}")
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||||
|
|
||||||
logger_handler.log_flask_error(
|
logger_handler.log_flask_error(
|
||||||
error_type="payroll_dashboard_error",
|
'payroll_dashboard_error',
|
||||||
error_message=str(e),
|
str(e),
|
||||||
stack_trace=traceback.format_exc()
|
stack_trace=traceback.format_exc()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -7173,8 +7139,8 @@ def export_payroll_excel():
|
|||||||
print(f"❌ Traceback: {traceback.format_exc()}")
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||||
|
|
||||||
logger_handler.log_flask_error(
|
logger_handler.log_flask_error(
|
||||||
error_type="payroll_excel_export_error",
|
'payroll_excel_export_error',
|
||||||
error_message=str(e),
|
str(e),
|
||||||
stack_trace=traceback.format_exc()
|
stack_trace=traceback.format_exc()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -7231,8 +7197,8 @@ def calculate_working_hours_api():
|
|||||||
|
|
||||||
attendance_records = query.all()
|
attendance_records = query.all()
|
||||||
|
|
||||||
# Calculate working hours using single check-in calculator
|
# Calculate working hours using WorkingHoursCalculator
|
||||||
calculator = SingleCheckInCalculator()
|
calculator = WorkingHoursCalculator()
|
||||||
hours_data = calculator.calculate_employee_hours(
|
hours_data = calculator.calculate_employee_hours(
|
||||||
str(employee_id), start_date, end_date, attendance_records
|
str(employee_id), start_date, end_date, attendance_records
|
||||||
)
|
)
|
||||||
@@ -7248,8 +7214,8 @@ def calculate_working_hours_api():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Error in calculate_working_hours_api: {e}")
|
print(f"❌ Error in calculate_working_hours_api: {e}")
|
||||||
logger_handler.log_flask_error(
|
logger_handler.log_flask_error(
|
||||||
error_type="working_hours_api_error",
|
'working_hours_api_error',
|
||||||
error_message=str(e),
|
str(e),
|
||||||
stack_trace=traceback.format_exc()
|
stack_trace=traceback.format_exc()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -7355,7 +7321,6 @@ def get_miss_punch_details(employee_id):
|
|||||||
converted_records.append(converted_record)
|
converted_records.append(converted_record)
|
||||||
|
|
||||||
# Calculate working hours using the same calculator as the dashboard
|
# Calculate working hours using the same calculator as the dashboard
|
||||||
#calculator = SingleCheckInCalculator()
|
|
||||||
|
|
||||||
# Calculate hours for this employee
|
# Calculate hours for this employee
|
||||||
hours_data = calculator.calculate_employee_hours(
|
hours_data = calculator.calculate_employee_hours(
|
||||||
@@ -7415,8 +7380,8 @@ def get_miss_punch_details(employee_id):
|
|||||||
print(f"❌ Traceback: {traceback.format_exc()}")
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||||
|
|
||||||
logger_handler.log_flask_error(
|
logger_handler.log_flask_error(
|
||||||
error_type="miss_punch_details_api_error",
|
'miss_punch_details_api_error',
|
||||||
error_message=str(e),
|
str(e),
|
||||||
stack_trace=traceback.format_exc()
|
stack_trace=traceback.format_exc()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -9451,7 +9416,7 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
|
|||||||
f"SP: {work_type_counts['SP']}, PW: {work_type_counts['PW']}"
|
f"SP: {work_type_counts['SP']}, PW: {work_type_counts['PW']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate working hours using SingleCheckInCalculator
|
# Calculate working hours using WorkingHoursCalculator
|
||||||
calculator = WorkingHoursCalculator()
|
calculator = WorkingHoursCalculator()
|
||||||
hours_data = calculator.calculate_all_employees_hours(
|
hours_data = calculator.calculate_all_employees_hours(
|
||||||
datetime.combine(start_date, datetime.min.time()),
|
datetime.combine(start_date, datetime.min.time()),
|
||||||
@@ -11217,8 +11182,8 @@ def update_existing_qr_codes():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger_handler.log_flask_error(
|
logger_handler.log_flask_error(
|
||||||
error_type="qr_code_update_error",
|
'qr_code_update_error',
|
||||||
error_message=f"Failed to update QR code {qr_code.id}: {str(e)}"
|
f"Failed to update QR code {qr_code.id}: {str(e)}"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -11437,8 +11402,8 @@ def get_optimized_statistics(date_from=None, date_to=None, project_filter=None):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger_handler.log_database_error(
|
logger_handler.log_database_error(
|
||||||
error_type="statistics_query_error",
|
'statistics_query_error',
|
||||||
error_message=str(e),
|
e,
|
||||||
query="get_optimized_statistics"
|
query="get_optimized_statistics"
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
@@ -11493,8 +11458,8 @@ if __name__ == '__main__':
|
|||||||
print(f"❌ Application startup failed: {e}")
|
print(f"❌ Application startup failed: {e}")
|
||||||
if hasattr(app, 'logger_handler'):
|
if hasattr(app, 'logger_handler'):
|
||||||
logger_handler.log_flask_error(
|
logger_handler.log_flask_error(
|
||||||
error_type="application_startup_error",
|
'application_startup_error',
|
||||||
error_message=str(e)
|
str(e)
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
+43
-23
@@ -25,7 +25,7 @@ import os
|
|||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from flask import request, session, g
|
from flask import request, session, g, render_template
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
import uuid
|
import uuid
|
||||||
@@ -187,17 +187,7 @@ class AppLogger:
|
|||||||
if self.app.debug:
|
if self.app.debug:
|
||||||
return None # Let Flask handle debug errors
|
return None # Let Flask handle debug errors
|
||||||
|
|
||||||
return '''
|
return render_template('errors/500.html'), 500
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head><title>Server Error</title></head>
|
|
||||||
<body style="font-family: Arial; text-align: center; margin-top: 100px;">
|
|
||||||
<h1>🔧 Something went wrong</h1>
|
|
||||||
<p>We're working to fix this issue. Please try again later.</p>
|
|
||||||
<a href="/" style="color: #2563eb;">← Back to Home</a>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
''', 500
|
|
||||||
|
|
||||||
@self.app.errorhandler(404)
|
@self.app.errorhandler(404)
|
||||||
def handle_not_found(error):
|
def handle_not_found(error):
|
||||||
@@ -208,17 +198,7 @@ class AppLogger:
|
|||||||
severity="LOW"
|
severity="LOW"
|
||||||
)
|
)
|
||||||
|
|
||||||
return '''
|
return render_template('errors/404.html'), 404
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head><title>Page Not Found</title></head>
|
|
||||||
<body style="font-family: Arial; text-align: center; margin-top: 100px;">
|
|
||||||
<h1>🔍 Page Not Found</h1>
|
|
||||||
<p>The page you're looking for doesn't exist.</p>
|
|
||||||
<a href="/" style="color: #2563eb;">← Back to Home</a>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
''', 404
|
|
||||||
|
|
||||||
def _get_request_context(self):
|
def _get_request_context(self):
|
||||||
"""Get current request context information"""
|
"""Get current request context information"""
|
||||||
@@ -836,6 +816,46 @@ def get_recent_logs(self, days=7, limit=100, category_filter=None, severity_filt
|
|||||||
print(f"Error in get_recent_logs: {e}")
|
print(f"Error in get_recent_logs: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def log_system_event(self, event_type, description, severity='INFO', additional_data=None):
|
||||||
|
"""Log system-level events such as startup, optimization, and slow queries"""
|
||||||
|
event_data = {
|
||||||
|
'system_event_type': event_type,
|
||||||
|
'severity': severity,
|
||||||
|
'event_timestamp': datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
if additional_data:
|
||||||
|
event_data['additional_data'] = additional_data
|
||||||
|
message = f"System event: {event_type} - {description}"
|
||||||
|
self.logger.info(json.dumps({'event': 'system_event', 'data': event_data}))
|
||||||
|
self._log_to_database(
|
||||||
|
event_type=event_type,
|
||||||
|
event_category='system',
|
||||||
|
description=message,
|
||||||
|
event_data=event_data,
|
||||||
|
severity=severity
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_user_activity(self, activity_type, description='', additional_data=None):
|
||||||
|
"""Log user activity events called directly from route handlers"""
|
||||||
|
context = self._get_request_context()
|
||||||
|
event_data = {
|
||||||
|
'activity_type': activity_type,
|
||||||
|
'event_timestamp': datetime.now().isoformat(),
|
||||||
|
'user_id': context.get('user_id'),
|
||||||
|
'username': context.get('username')
|
||||||
|
}
|
||||||
|
if additional_data:
|
||||||
|
event_data['additional_data'] = additional_data
|
||||||
|
message = f"User activity: {activity_type} - {description}" if description else f"User activity: {activity_type}"
|
||||||
|
self.logger.info(json.dumps({'event': 'user_activity', 'data': event_data}))
|
||||||
|
self._log_to_database(
|
||||||
|
event_type=f'user_activity_{activity_type}',
|
||||||
|
event_category='activity',
|
||||||
|
description=message,
|
||||||
|
event_data=event_data,
|
||||||
|
severity='INFO'
|
||||||
|
)
|
||||||
|
|
||||||
# DECORATOR FUNCTIONS FOR AUTOMATIC LOGGING
|
# DECORATOR FUNCTIONS FOR AUTOMATIC LOGGING
|
||||||
|
|
||||||
def log_user_activity(activity_type):
|
def log_user_activity(activity_type):
|
||||||
|
|||||||
+32
-28
@@ -1,64 +1,68 @@
|
|||||||
# QR Code Management System - Python Dependencies
|
# QR Code Management System - Python Dependencies
|
||||||
# Production-ready Flask application with MySQL support
|
# Production-ready Flask application with MySQL support
|
||||||
|
# Versions pinned as of March 2026 — run `pip install -r requirements.txt` on fresh deploy
|
||||||
|
|
||||||
# Core Flask Framework
|
# Core Flask Framework
|
||||||
Flask
|
Flask==3.1.0
|
||||||
Flask-SQLAlchemy
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
|
||||||
# Database Support - MySQL
|
# Database Support - MySQL
|
||||||
PyMySQL # Pure Python MySQL client
|
PyMySQL==1.1.1 # Pure Python MySQL client
|
||||||
mysql-connector-python # Official MySQL connector (alternative)
|
mysql-connector-python==9.2.0 # Official MySQL connector (alternative)
|
||||||
SQLAlchemy
|
SQLAlchemy==2.0.36
|
||||||
|
|
||||||
# Security and Authentication
|
# Security and Authentication
|
||||||
Werkzeug # Security utilities and password hashing
|
Werkzeug==3.1.3 # Security utilities and password hashing
|
||||||
|
|
||||||
# QR Code Generation
|
# QR Code Generation
|
||||||
qrcode # QR code generation library
|
qrcode==8.0 # QR code generation library
|
||||||
Pillow # Image processing for QR codes
|
Pillow==11.1.0 # Image processing for QR codes
|
||||||
|
|
||||||
# User Agent Detection
|
# User Agent Detection
|
||||||
user-agents # Device and browser detection from user agent strings
|
user-agents==2.2.0 # Device and browser detection from user agent strings
|
||||||
|
|
||||||
# URL and Regex Processing
|
# URL and Regex Processing
|
||||||
regex # Enhanced regex support for URL generation
|
regex==2024.11.6 # Enhanced regex support for URL generation
|
||||||
|
|
||||||
# Environment and Configuration
|
# Environment and Configuration
|
||||||
python-dotenv # Environment variable management
|
python-dotenv==1.0.1 # Environment variable management
|
||||||
|
|
||||||
# Date and Time Processing
|
# Date and Time Processing
|
||||||
python-dateutil # Extended date/time processing
|
python-dateutil==2.9.0.post0 # Extended date/time processing
|
||||||
|
|
||||||
# Development and Testing (optional)
|
# Development and Testing (optional)
|
||||||
pytest # Testing framework
|
pytest==8.3.4 # Testing framework
|
||||||
pytest-flask # Flask testing utilities
|
pytest-flask==1.3.0 # Flask testing utilities
|
||||||
Flask-Testing # Additional Flask testing tools
|
Flask-Testing==0.8.1 # Additional Flask testing tools
|
||||||
|
|
||||||
# Production Server (optional)
|
# Production Server (optional)
|
||||||
gunicorn # WSGI HTTP Server for production
|
gunicorn==23.0.0 # WSGI HTTP Server for production
|
||||||
gevent # Async worker support
|
gevent==24.11.1 # Async worker support
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
click # Command line interface creation
|
click==8.1.8 # Command line interface creation
|
||||||
itsdangerous # Secure data serialization
|
itsdangerous==2.2.0 # Secure data serialization
|
||||||
Jinja2 # Template engine
|
Jinja2==3.1.5 # Template engine
|
||||||
MarkupSafe # Safe string handling
|
MarkupSafe==3.0.2 # Safe string handling
|
||||||
|
|
||||||
# Data Export and Processing
|
# Data Export and Processing
|
||||||
openpyxl # Excel file generation for attendance reports
|
openpyxl==3.1.5 # Excel file generation for attendance reports
|
||||||
pandas # Data manipulation for reports (optional)
|
pandas==2.2.3 # Data manipulation for reports (optional)
|
||||||
|
|
||||||
# HTTP Requests (for potential integrations)
|
# HTTP Requests (for potential integrations)
|
||||||
requests # HTTP library for external API calls
|
requests==2.32.3 # HTTP library for external API calls
|
||||||
|
|
||||||
|
# Google Maps Integration
|
||||||
|
googlemaps==4.10.0 # Google Maps API client
|
||||||
|
|
||||||
# Caching (optional for performance)
|
# Caching (optional for performance)
|
||||||
Flask-Caching # Caching support for Flask
|
Flask-Caching==2.3.0 # Caching support for Flask
|
||||||
|
|
||||||
# Logging and Monitoring (optional)
|
# Logging and Monitoring (optional)
|
||||||
python-json-logger # Structured logging support
|
python-json-logger==3.2.1 # Structured logging support
|
||||||
|
|
||||||
# Cryptography dependencies (required for some MySQL features)
|
# Cryptography dependencies (required for some MySQL features)
|
||||||
cryptography # Required for MySQL SSL connections
|
cryptography==44.0.0 # Required for MySQL SSL connections
|
||||||
|
|
||||||
# Employee Synchronization Dependencies
|
# Employee Synchronization Dependencies
|
||||||
schedule # For automated scheduling
|
schedule==1.2.2 # For automated scheduling
|
||||||
@@ -1,573 +0,0 @@
|
|||||||
/**
|
|
||||||
* Dashboard-specific JavaScript functionality with QR Toggle
|
|
||||||
* static/js/dashboard.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Dashboard QR Management Class
|
|
||||||
class DashboardManager {
|
|
||||||
constructor() {
|
|
||||||
this.allExpanded = false;
|
|
||||||
this.currentModalQR = null;
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.initializeSearch();
|
|
||||||
this.initializeFilters();
|
|
||||||
this.setupEventListeners();
|
|
||||||
this.addScrollAnimations();
|
|
||||||
this.updateResultsCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize search functionality with debouncing
|
|
||||||
initializeSearch() {
|
|
||||||
const searchInput = document.getElementById("qrSearch");
|
|
||||||
if (!searchInput) return;
|
|
||||||
|
|
||||||
let searchTimeout;
|
|
||||||
searchInput.addEventListener("input", () => {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
this.filterQRCodes();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize filter functionality
|
|
||||||
initializeFilters() {
|
|
||||||
const statusFilter = document.getElementById("statusFilter");
|
|
||||||
if (!statusFilter) return;
|
|
||||||
|
|
||||||
statusFilter.addEventListener("change", () => {
|
|
||||||
this.filterQRCodes();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced QR code filtering with animation
|
|
||||||
filterQRCodes() {
|
|
||||||
const searchTerm = document.getElementById("qrSearch").value.toLowerCase();
|
|
||||||
const statusFilter = document.getElementById("statusFilter").value;
|
|
||||||
const qrItems = document.querySelectorAll(".qr-item");
|
|
||||||
|
|
||||||
let visibleCount = 0;
|
|
||||||
|
|
||||||
qrItems.forEach((item) => {
|
|
||||||
const name = item.dataset.name || "";
|
|
||||||
const location = item.dataset.location || "";
|
|
||||||
const status = item.dataset.status || "";
|
|
||||||
|
|
||||||
const matchesSearch =
|
|
||||||
!searchTerm ||
|
|
||||||
name.includes(searchTerm) ||
|
|
||||||
location.includes(searchTerm);
|
|
||||||
const matchesStatus = !statusFilter || status === statusFilter;
|
|
||||||
|
|
||||||
if (matchesSearch && matchesStatus) {
|
|
||||||
this.showQRItem(item);
|
|
||||||
visibleCount++;
|
|
||||||
} else {
|
|
||||||
this.hideQRItem(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.updateResultsDisplay(visibleCount);
|
|
||||||
this.updateResultsCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show QR item with animation
|
|
||||||
showQRItem(item) {
|
|
||||||
item.style.display = "block";
|
|
||||||
setTimeout(() => {
|
|
||||||
item.classList.add("fade-in");
|
|
||||||
item.classList.remove("fade-out");
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide QR item with animation
|
|
||||||
hideQRItem(item) {
|
|
||||||
item.classList.add("fade-out");
|
|
||||||
item.classList.remove("fade-in");
|
|
||||||
setTimeout(() => {
|
|
||||||
item.style.display = "none";
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update results display and empty state
|
|
||||||
updateResultsDisplay(count) {
|
|
||||||
const qrList = document.getElementById("qrList");
|
|
||||||
let existingEmpty = document.querySelector(".search-empty-state");
|
|
||||||
|
|
||||||
if (
|
|
||||||
count === 0 &&
|
|
||||||
(document.getElementById("qrSearch").value ||
|
|
||||||
document.getElementById("statusFilter").value)
|
|
||||||
) {
|
|
||||||
if (!existingEmpty) {
|
|
||||||
const emptyState = document.createElement("div");
|
|
||||||
emptyState.className = "search-empty-state";
|
|
||||||
emptyState.innerHTML = `
|
|
||||||
<div class="empty-icon">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
</div>
|
|
||||||
<h3>No QR Codes Found</h3>
|
|
||||||
<p>Try adjusting your search or filter criteria</p>
|
|
||||||
<button onclick="dashboardManager.clearFilters()" class="btn btn-outline">
|
|
||||||
<i class="fas fa-refresh"></i>
|
|
||||||
Clear Filters
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
qrList.parentNode.appendChild(emptyState);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (existingEmpty) {
|
|
||||||
existingEmpty.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear all filters
|
|
||||||
clearFilters() {
|
|
||||||
document.getElementById("qrSearch").value = "";
|
|
||||||
document.getElementById("statusFilter").value = "";
|
|
||||||
this.filterQRCodes();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update results counter
|
|
||||||
updateResultsCount() {
|
|
||||||
const qrItems = document.querySelectorAll(
|
|
||||||
'.qr-item[style*="block"], .qr-item:not([style*="none"])'
|
|
||||||
);
|
|
||||||
const totalItems = document.querySelectorAll(".qr-item").length;
|
|
||||||
const visibleCount = qrItems.length;
|
|
||||||
|
|
||||||
let counter = document.querySelector(".results-counter");
|
|
||||||
if (!counter) {
|
|
||||||
counter = document.createElement("div");
|
|
||||||
counter.className = "results-counter";
|
|
||||||
const searchContainer = document.querySelector(".search-container");
|
|
||||||
if (searchContainer) {
|
|
||||||
searchContainer.appendChild(counter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibleCount !== totalItems) {
|
|
||||||
counter.textContent = `Showing ${visibleCount} of ${totalItems} QR codes`;
|
|
||||||
counter.style.display = "block";
|
|
||||||
} else {
|
|
||||||
counter.style.display = "none";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup additional event listeners
|
|
||||||
setupEventListeners() {
|
|
||||||
// Keyboard shortcuts
|
|
||||||
document.addEventListener("keydown", (e) => {
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
switch (e.key) {
|
|
||||||
case "f":
|
|
||||||
e.preventDefault();
|
|
||||||
document.getElementById("qrSearch")?.focus();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enhanced modal functionality
|
|
||||||
this.setupModalHandling();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced modal handling
|
|
||||||
setupModalHandling() {
|
|
||||||
const modal = document.getElementById("qrModal");
|
|
||||||
if (!modal) return;
|
|
||||||
|
|
||||||
// Close modal with better animation
|
|
||||||
const closeButtons = modal.querySelectorAll("[onclick*='closeQRModal']");
|
|
||||||
closeButtons.forEach((btn) => {
|
|
||||||
btn.addEventListener("click", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
this.closeQRModal();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click outside to close
|
|
||||||
modal.addEventListener("click", (e) => {
|
|
||||||
if (e.target === modal) {
|
|
||||||
this.closeQRModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Escape key to close
|
|
||||||
document.addEventListener("keydown", (e) => {
|
|
||||||
if (e.key === "Escape" && modal.style.display === "flex") {
|
|
||||||
this.closeQRModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close QR modal with improved animation
|
|
||||||
closeQRModal() {
|
|
||||||
const modal = document.getElementById("qrModal");
|
|
||||||
if (modal) {
|
|
||||||
modal.classList.remove("show");
|
|
||||||
setTimeout(() => {
|
|
||||||
modal.style.display = "none";
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
this.currentModalQR = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add scroll animations for better UX
|
|
||||||
addScrollAnimations() {
|
|
||||||
const observerOptions = {
|
|
||||||
threshold: 0.1,
|
|
||||||
rootMargin: "0px 0px -50px 0px",
|
|
||||||
};
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
entry.target.style.opacity = "1";
|
|
||||||
entry.target.style.transform = "translateY(0)";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, observerOptions);
|
|
||||||
|
|
||||||
// Observe QR items
|
|
||||||
document.querySelectorAll(".qr-item").forEach((item) => {
|
|
||||||
item.style.opacity = "0";
|
|
||||||
item.style.transform = "translateY(20px)";
|
|
||||||
item.style.transition = "opacity 0.6s ease, transform 0.6s ease";
|
|
||||||
observer.observe(item);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle QR details with smooth animation
|
|
||||||
toggleQRDetails(qrId) {
|
|
||||||
const details = document.getElementById(`qr-details-${qrId}`);
|
|
||||||
const chevron = document.querySelector(
|
|
||||||
`[onclick="toggleQRDetails(${qrId})"] .chevron`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!details) return;
|
|
||||||
|
|
||||||
if (details.classList.contains("expanded")) {
|
|
||||||
details.classList.remove("expanded");
|
|
||||||
if (chevron) chevron.style.transform = "rotate(0deg)";
|
|
||||||
} else {
|
|
||||||
// Close other expanded items first
|
|
||||||
document
|
|
||||||
.querySelectorAll(".qr-item-details.expanded")
|
|
||||||
.forEach((detail) => {
|
|
||||||
if (detail !== details) {
|
|
||||||
detail.classList.remove("expanded");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
details.classList.add("expanded");
|
|
||||||
if (chevron) chevron.style.transform = "rotate(180deg)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle all QR codes expand/collapse
|
|
||||||
toggleAllQRs() {
|
|
||||||
const details = document.querySelectorAll(".qr-item-details");
|
|
||||||
const expandIcon = document.getElementById("expandIcon");
|
|
||||||
const expandText = document.getElementById("expandText");
|
|
||||||
|
|
||||||
this.allExpanded = !this.allExpanded;
|
|
||||||
|
|
||||||
details.forEach((detail) => {
|
|
||||||
if (this.allExpanded) {
|
|
||||||
detail.classList.add("expanded");
|
|
||||||
} else {
|
|
||||||
detail.classList.remove("expanded");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update button text and icon
|
|
||||||
if (expandIcon && expandText) {
|
|
||||||
if (this.allExpanded) {
|
|
||||||
expandIcon.className = "fas fa-compress-alt";
|
|
||||||
expandText.textContent = "Collapse All";
|
|
||||||
} else {
|
|
||||||
expandIcon.className = "fas fa-expand-alt";
|
|
||||||
expandText.textContent = "Expand All";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update chevron icons
|
|
||||||
document.querySelectorAll(".chevron").forEach((chevron) => {
|
|
||||||
chevron.style.transform = this.allExpanded
|
|
||||||
? "rotate(180deg)"
|
|
||||||
: "rotate(0deg)";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced QR preview functionality
|
|
||||||
previewQR(qrData, qrName) {
|
|
||||||
const modal = document.getElementById("qrModal");
|
|
||||||
const modalTitle = document.getElementById("modalTitle");
|
|
||||||
const modalImage = document.getElementById("modalQRImage");
|
|
||||||
|
|
||||||
if (modal && modalTitle && modalImage) {
|
|
||||||
modalTitle.textContent = `${qrName} - QR Code`;
|
|
||||||
modalImage.src = `data:image/png;base64,${qrData}`;
|
|
||||||
modalImage.alt = `QR Code for ${qrName}`;
|
|
||||||
|
|
||||||
this.currentModalQR = {
|
|
||||||
name: qrName,
|
|
||||||
image: `data:image/png;base64,${qrData}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
modal.style.display = "flex";
|
|
||||||
setTimeout(() => modal.classList.add("show"), 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced download functionality
|
|
||||||
downloadQR(base64Image, filename) {
|
|
||||||
try {
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = `data:image/png;base64,${base64Image}`;
|
|
||||||
link.download = `${filename
|
|
||||||
.replace(/[^a-z0-9]/gi, "_")
|
|
||||||
.toLowerCase()}_qr_code.png`;
|
|
||||||
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
|
|
||||||
// Show success toast
|
|
||||||
this.showToast("QR code downloaded successfully!", "success");
|
|
||||||
} catch (error) {
|
|
||||||
this.showToast("Failed to download QR code", "error");
|
|
||||||
console.error("Download error:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download from modal
|
|
||||||
downloadModalQR() {
|
|
||||||
if (this.currentModalQR) {
|
|
||||||
const base64Data = this.currentModalQR.image.split("base64,")[1];
|
|
||||||
this.downloadQR(base64Data, this.currentModalQR.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NEW: Toggle QR Code Status (Activate/Deactivate)
|
|
||||||
async toggleQRCodeStatus(qrId) {
|
|
||||||
const qrItem = document.querySelector(`[data-qr-id="${qrId}"]`);
|
|
||||||
const statusElement = document.getElementById(`status-${qrId}`);
|
|
||||||
const statusIcon = document.getElementById(`status-icon-${qrId}`);
|
|
||||||
const statusText = document.getElementById(`status-text-${qrId}`);
|
|
||||||
const toggleBtn = document.getElementById(`toggle-btn-${qrId}`);
|
|
||||||
const toggleIcon = document.getElementById(`toggle-icon-${qrId}`);
|
|
||||||
const detailToggleBtn = document.getElementById(
|
|
||||||
`detail-toggle-btn-${qrId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!statusElement || !qrItem) return;
|
|
||||||
|
|
||||||
// Add loading state
|
|
||||||
statusElement.classList.add("status-loading");
|
|
||||||
if (toggleBtn) toggleBtn.disabled = true;
|
|
||||||
if (detailToggleBtn) detailToggleBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/qr-codes/${qrId}/toggle-status`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
// Update UI elements
|
|
||||||
const newStatus = data.new_status;
|
|
||||||
const newStatusClass = newStatus ? "active" : "inactive";
|
|
||||||
const newIconClass = newStatus ? "fa-check-circle" : "fa-times-circle";
|
|
||||||
const newToggleIcon = newStatus ? "fa-pause" : "fa-play";
|
|
||||||
const newToggleBtnClass = newStatus ? "btn-deactivate" : "btn-activate";
|
|
||||||
const newDetailBtnClass = newStatus ? "btn-warning" : "btn-success";
|
|
||||||
const newDetailBtnText = newStatus ? "Deactivate" : "Activate";
|
|
||||||
|
|
||||||
// Update status badge
|
|
||||||
statusElement.className = `qr-status ${newStatusClass}`;
|
|
||||||
if (statusIcon) statusIcon.className = `fas ${newIconClass}`;
|
|
||||||
if (statusText) statusText.textContent = data.status_text;
|
|
||||||
|
|
||||||
// Update QR item data attribute and styling
|
|
||||||
qrItem.setAttribute("data-status", newStatusClass);
|
|
||||||
|
|
||||||
// Update toggle button (collapsed view)
|
|
||||||
if (toggleBtn) {
|
|
||||||
toggleBtn.className = `action-btn btn-status ${newToggleBtnClass}`;
|
|
||||||
toggleBtn.title = `${newDetailBtnText} QR Code`;
|
|
||||||
}
|
|
||||||
if (toggleIcon) {
|
|
||||||
toggleIcon.className = `fas ${newToggleIcon}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update detail toggle button (expanded view)
|
|
||||||
if (detailToggleBtn) {
|
|
||||||
detailToggleBtn.className = `btn ${newDetailBtnClass}`;
|
|
||||||
detailToggleBtn.innerHTML = `<i class="fas ${newToggleIcon}"></i> ${newDetailBtnText} QR Code`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status badges in expanded view
|
|
||||||
const detailStatusBadges = qrItem.querySelectorAll(".status-badge");
|
|
||||||
detailStatusBadges.forEach((badge) => {
|
|
||||||
badge.className = `status-badge ${newStatusClass}`;
|
|
||||||
const icon = badge.querySelector("i");
|
|
||||||
if (icon) icon.className = `fas ${newIconClass}`;
|
|
||||||
const text = badge.textContent.trim();
|
|
||||||
if (text === "Active" || text === "Inactive") {
|
|
||||||
badge.innerHTML = `<i class="fas ${newIconClass}"></i> ${data.status_text}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
this.showToast(data.message, "success");
|
|
||||||
|
|
||||||
// Update statistics if needed
|
|
||||||
this.updateStatistics();
|
|
||||||
} else {
|
|
||||||
this.showToast(
|
|
||||||
data.message || "Failed to update QR code status",
|
|
||||||
"error"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error toggling QR status:", error);
|
|
||||||
this.showToast("Network error. Please try again.", "error");
|
|
||||||
} finally {
|
|
||||||
// Remove loading state
|
|
||||||
statusElement.classList.remove("status-loading");
|
|
||||||
if (toggleBtn) toggleBtn.disabled = false;
|
|
||||||
if (detailToggleBtn) detailToggleBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update statistics after status change
|
|
||||||
updateStatistics() {
|
|
||||||
const activeCount = document.querySelectorAll(
|
|
||||||
'[data-status="active"]'
|
|
||||||
).length;
|
|
||||||
const inactiveCount = document.querySelectorAll(
|
|
||||||
'[data-status="inactive"]'
|
|
||||||
).length;
|
|
||||||
const totalCount = activeCount + inactiveCount;
|
|
||||||
|
|
||||||
// Update active count
|
|
||||||
const activeStatElement = document.querySelector(".stat-card.success h3");
|
|
||||||
if (activeStatElement) {
|
|
||||||
activeStatElement.textContent = activeCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update active percentage
|
|
||||||
const activePercentElement = document.querySelector(
|
|
||||||
".stat-card.success .stat-trend"
|
|
||||||
);
|
|
||||||
if (activePercentElement && totalCount > 0) {
|
|
||||||
const percentage = ((activeCount / totalCount) * 100).toFixed(1);
|
|
||||||
activePercentElement.textContent = `${percentage}% active`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update inactive count if there's a specific stat card for it
|
|
||||||
const inactiveStatElement = document.querySelector(".stat-card.warning h3");
|
|
||||||
if (inactiveStatElement) {
|
|
||||||
inactiveStatElement.textContent = inactiveCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update inactive percentage
|
|
||||||
const inactivePercentElement = document.querySelector(
|
|
||||||
".stat-card.warning .stat-trend"
|
|
||||||
);
|
|
||||||
if (inactivePercentElement && totalCount > 0) {
|
|
||||||
const percentage = ((inactiveCount / totalCount) * 100).toFixed(1);
|
|
||||||
inactivePercentElement.textContent = `${percentage}% inactive`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toast notification system
|
|
||||||
showToast(message, type = "info") {
|
|
||||||
const toast = document.createElement("div");
|
|
||||||
toast.className = `toast toast-${type}`;
|
|
||||||
toast.innerHTML = `
|
|
||||||
<div class="toast-content">
|
|
||||||
<i class="fas ${this.getToastIcon(type)}"></i>
|
|
||||||
<span>${message}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
// Animate in
|
|
||||||
setTimeout(() => toast.classList.add("show"), 100);
|
|
||||||
|
|
||||||
// Auto remove
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.classList.remove("show");
|
|
||||||
setTimeout(() => {
|
|
||||||
if (document.body.contains(toast)) {
|
|
||||||
document.body.removeChild(toast);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get toast icon based on type
|
|
||||||
getToastIcon(type) {
|
|
||||||
const icons = {
|
|
||||||
success: "fa-check-circle",
|
|
||||||
error: "fa-exclamation-circle",
|
|
||||||
warning: "fa-exclamation-triangle",
|
|
||||||
info: "fa-info-circle",
|
|
||||||
};
|
|
||||||
return icons[type] || icons.info;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global functions for compatibility with existing onclick handlers
|
|
||||||
let dashboardManager;
|
|
||||||
|
|
||||||
function toggleQRDetails(qrId) {
|
|
||||||
dashboardManager?.toggleQRDetails(qrId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAllQRs() {
|
|
||||||
dashboardManager?.toggleAllQRs();
|
|
||||||
}
|
|
||||||
|
|
||||||
function previewQR(qrData, qrName) {
|
|
||||||
dashboardManager?.previewQR(qrData, qrName);
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadQR(base64Image, filename) {
|
|
||||||
dashboardManager?.downloadQR(base64Image, filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeQRModal() {
|
|
||||||
dashboardManager?.closeQRModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadModalQR() {
|
|
||||||
dashboardManager?.downloadModalQR();
|
|
||||||
}
|
|
||||||
|
|
||||||
// NEW: Global function for QR status toggle
|
|
||||||
function toggleQRCodeStatus(qrId) {
|
|
||||||
dashboardManager?.toggleQRCodeStatus(qrId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize dashboard when DOM is ready
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
dashboardManager = new DashboardManager();
|
|
||||||
|
|
||||||
// Add helpful keyboard shortcuts tooltip
|
|
||||||
console.log("Dashboard keyboard shortcuts:");
|
|
||||||
console.log("Ctrl/Cmd + F: Focus search");
|
|
||||||
console.log("Escape: Close modal");
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user