Add batch processing

This commit is contained in:
pjmalandrino 2025-05-27 11:20:13 +02:00
parent 3faf61d4f0
commit 306c134f74
6 changed files with 2504 additions and 389 deletions

4
.gitignore vendored
View file

@ -2,3 +2,7 @@
document_output
results
handbook.pdf
ariston.pdf
.DS_Store
__pycache__/
perplexity.pdf

354
app.py
View file

@ -5,6 +5,9 @@ import time
import threading
import logging
from pathlib import Path
import sys
import uuid
import pdf2image
# Configure logging
logging.basicConfig(level=logging.INFO,
@ -19,6 +22,14 @@ app = Flask(__name__,
# Dictionary to store background task results
task_results = {}
# Import batch processor if available
try:
from batch_processor import start_batch_processing, get_batch_processor, cleanup_old_batches
batch_processing_available = True
except ImportError:
logger.warning("batch_processor.py not found. Batch processing features will be disabled.")
batch_processing_available = False
# Ensure results folder exists
def ensure_results_folder():
results_dir = Path("results")
@ -44,6 +55,11 @@ def ensure_frontend_folders():
def index():
return send_file('frontend/index.html')
@app.route('/batch')
def batch_interface():
"""Serve the batch processing interface"""
return send_file('frontend/batch.html')
@app.route('/static/<path:filename>')
def serve_static(filename):
return send_file(os.path.join('frontend/static', filename))
@ -59,6 +75,46 @@ def pdf_files():
logger.error(f"Error listing PDF files: {str(e)}")
return jsonify({"error": str(e)}), 500
@app.route('/pdf-info/<path:pdf_file>')
def pdf_info(pdf_file):
"""Get information about a PDF file (page count, etc.)"""
try:
if not os.path.exists(pdf_file):
return jsonify({'error': f'PDF file not found: {pdf_file}'}), 404
# Get page count using pdf2image
try:
from pdf2image.pdf2image import pdfinfo_from_path
info = pdfinfo_from_path(pdf_file)
page_count = info["Pages"]
except Exception as e:
# Fallback method
logger.warning(f"pdfinfo failed, using fallback: {e}")
images = pdf2image.convert_from_path(pdf_file, dpi=72, first_page=1, last_page=1)
page_count = 1 # At least one page if we got here
# Try to get actual count by checking last pages
for i in range(2, 1000): # Reasonable upper limit
try:
images = pdf2image.convert_from_path(pdf_file, dpi=72, first_page=i, last_page=i)
if not images:
page_count = i - 1
break
page_count = i
except:
page_count = i - 1
break
return jsonify({
'pageCount': page_count,
'filename': os.path.basename(pdf_file),
'size': os.path.getsize(pdf_file)
})
except Exception as e:
logger.error(f"Error getting PDF info: {str(e)}")
return jsonify({'error': str(e)}), 500
def run_command(task_id, command):
"""Run a command in a background thread and store result"""
logger.info(f"Running command: {command}")
@ -383,7 +439,8 @@ def check_environment():
'pdf_files': pdf_files,
'results_dir_exists': results_dir_exists,
'results_dir_writable': results_writable,
'python_version': python_version
'python_version': python_version,
'batch_processing_available': batch_processing_available
})
except Exception as e:
import traceback
@ -440,8 +497,303 @@ def run_manual_command():
logger.error(f"Error running manual command: {str(e)}")
return jsonify({'success': False, 'error': str(e)}), 500
# Batch processing endpoints
@app.route('/run-batch-processor', methods=['POST'])
def run_batch_processor():
"""Start a new batch processing job"""
if not batch_processing_available:
return jsonify({'success': False, 'error': 'Batch processing not available'}), 503
try:
pdf_file = request.form.get('pdf_file')
start_page = int(request.form.get('start_page', 1))
end_page = int(request.form.get('end_page', 1))
if not pdf_file:
return jsonify({'success': False, 'error': 'PDF file not specified'}), 400
if not os.path.exists(pdf_file):
return jsonify({'success': False, 'error': f'PDF file not found: {pdf_file}'}), 404
# Processing options
options = {
'adjust': request.form.get('adjust') == 'true',
'parallel': request.form.get('parallel') == 'true',
'generate_report': request.form.get('generate_report') == 'true'
}
# Generate batch ID
batch_id = str(uuid.uuid4())[:8]
# Start batch processing
if start_batch_processing(batch_id, pdf_file, start_page, end_page, options):
logger.info(f"Started batch processing with ID: {batch_id}")
return jsonify({
'success': True,
'batch_id': batch_id,
'message': f'Batch processing started for {end_page - start_page + 1} pages'
})
else:
return jsonify({'success': False, 'error': 'Failed to start batch processing'}), 500
except Exception as e:
logger.error(f"Error starting batch processor: {str(e)}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/batch-status/<batch_id>')
def batch_status(batch_id):
"""Get the status of a batch processing job"""
if not batch_processing_available:
return jsonify({'error': 'Batch processing not available'}), 503
try:
processor = get_batch_processor(batch_id)
if not processor:
return jsonify({'error': 'Batch not found'}), 404
state = processor.get_state()
# Only return recent logs (last 20)
state['logs'] = state['logs'][-20:]
return jsonify(state)
except Exception as e:
logger.error(f"Error getting batch status: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/pause-batch/<batch_id>', methods=['POST'])
def pause_batch(batch_id):
"""Pause a batch processing job"""
if not batch_processing_available:
return jsonify({'error': 'Batch processing not available'}), 503
try:
processor = get_batch_processor(batch_id)
if not processor:
return jsonify({'error': 'Batch not found'}), 404
processor.pause()
return jsonify({'success': True})
except Exception as e:
logger.error(f"Error pausing batch: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/resume-batch/<batch_id>', methods=['POST'])
def resume_batch(batch_id):
"""Resume a paused batch processing job"""
if not batch_processing_available:
return jsonify({'error': 'Batch processing not available'}), 503
try:
processor = get_batch_processor(batch_id)
if not processor:
return jsonify({'error': 'Batch not found'}), 404
processor.resume()
return jsonify({'success': True})
except Exception as e:
logger.error(f"Error resuming batch: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/cancel-batch/<batch_id>', methods=['POST'])
def cancel_batch(batch_id):
"""Cancel a batch processing job"""
if not batch_processing_available:
return jsonify({'error': 'Batch processing not available'}), 503
try:
processor = get_batch_processor(batch_id)
if not processor:
return jsonify({'error': 'Batch not found'}), 404
processor.cancel()
return jsonify({'success': True})
except Exception as e:
logger.error(f"Error cancelling batch: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/retry-page', methods=['POST'])
def retry_page():
"""Retry processing a single failed page"""
if not batch_processing_available:
return jsonify({'success': False, 'error': 'Batch processing not available'}), 503
try:
pdf_file = request.form.get('pdf_file')
page_num = int(request.form.get('page_num'))
adjust = request.form.get('adjust') == 'true'
if not pdf_file or not page_num:
return jsonify({'success': False, 'error': 'Missing parameters'}), 400
# Create a single-page batch for retry
batch_id = f"retry_{uuid.uuid4().hex[:8]}"
options = {'adjust': adjust, 'parallel': False, 'generate_report': False}
if start_batch_processing(batch_id, pdf_file, page_num, page_num, options):
# Wait for completion (since it's just one page)
processor = get_batch_processor(batch_id)
timeout = 60 # 60 seconds timeout
start_time = time.time()
while not processor.state['completed'] and (time.time() - start_time) < timeout:
time.sleep(0.5)
if processor.state['results']['successful'] > 0:
return jsonify({'success': True})
else:
return jsonify({'success': False, 'error': 'Page processing failed'})
else:
return jsonify({'success': False, 'error': 'Failed to start retry'}), 500
except Exception as e:
logger.error(f"Error retrying page: {str(e)}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/download-batch-results/<batch_id>')
def download_batch_results(batch_id):
"""Download all batch results as a ZIP file"""
if not batch_processing_available:
return jsonify({'error': 'Batch processing not available'}), 503
try:
processor = get_batch_processor(batch_id)
if not processor:
return jsonify({'error': 'Batch not found'}), 404
# Create ZIP archive
zip_path = processor.create_zip_archive()
if zip_path and os.path.exists(zip_path):
return send_file(
zip_path,
mimetype='application/zip',
as_attachment=True,
download_name=f'batch_results_{batch_id}.zip'
)
else:
return jsonify({'error': 'Failed to create archive'}), 500
except Exception as e:
logger.error(f"Error downloading batch results: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/batch-report/<batch_id>')
def batch_report(batch_id):
"""View the batch processing report"""
if not batch_processing_available:
return jsonify({'error': 'Batch processing not available'}), 503
try:
processor = get_batch_processor(batch_id)
if not processor:
return jsonify({'error': 'Batch not found'}), 404
report_path = processor.results_dir / "report.html"
if report_path.exists():
# Read and modify the HTML to fix image paths
with open(report_path, 'r') as f:
html_content = f.read()
# Replace relative image paths with absolute Flask routes
html_content = html_content.replace(
'src="visualization_page_',
f'src="/batch-report-image/{batch_id}/visualization_page_'
)
html_content = html_content.replace(
'href="visualization_page_',
f'href="/batch-report-image/{batch_id}/visualization_page_'
)
return html_content
else:
return jsonify({'error': 'Report not found'}), 404
except Exception as e:
logger.error(f"Error viewing batch report: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/batch-report-image/<batch_id>/<path:filename>')
def batch_report_image(batch_id, filename):
"""Serve images from batch report directory"""
if not batch_processing_available:
return jsonify({'error': 'Batch processing not available'}), 503
try:
processor = get_batch_processor(batch_id)
if not processor:
return jsonify({'error': 'Batch not found'}), 404
image_path = processor.results_dir / filename
if image_path.exists() and image_path.is_file():
return send_file(image_path)
else:
return jsonify({'error': f'Image not found: {filename}'}), 404
except Exception as e:
logger.error(f"Error serving batch report image: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/open-results-folder', methods=['POST'])
def open_results_folder():
"""Open the results folder in the system file explorer"""
try:
results_path = os.path.abspath("results")
if sys.platform == 'darwin': # macOS
subprocess.run(['open', results_path])
elif sys.platform == 'win32': # Windows
os.startfile(results_path)
else: # Linux
subprocess.run(['xdg-open', results_path])
return jsonify({'success': True})
except Exception as e:
logger.error(f"Error opening results folder: {str(e)}")
return jsonify({'success': False, 'error': str(e)}), 500
# Cleanup task for batch processors
def cleanup_task():
"""Periodic cleanup of old batch processors"""
if not batch_processing_available:
return
while True:
time.sleep(3600) # Run every hour
try:
cleanup_old_batches(24) # Clean up batches older than 24 hours
except Exception as e:
logger.error(f"Error in cleanup task: {str(e)}")
# Start cleanup thread when app starts (only if batch processing is available)
if batch_processing_available:
cleanup_thread = threading.Thread(target=cleanup_task)
cleanup_thread.daemon = True
cleanup_thread.start()
if __name__ == '__main__':
# Ensure folders exist
ensure_results_folder()
ensure_frontend_folders()
# Check environment
if not batch_processing_available:
logger.warning("batch_processor.py not found. Batch processing features will be disabled.")
else:
logger.info("Batch processing features enabled.")
app.run(debug=True, host='127.0.0.1', port=5000)

File diff suppressed because it is too large Load diff

251
frontend/batch.html Normal file
View file

@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocTags Batch Processor</title>
<link rel="stylesheet" href="static/styles.css">
<link rel="stylesheet" href="static/batch-styles.css">
</head>
<body>
<div class="container">
<h1>DocTags Batch Processor</h1>
<!-- Back to Single Page Processor -->
<div class="nav-link">
<a href="/">← Back to Single Page Processor</a>
</div>
<!-- Settings Panel -->
<div class="settings-panel">
<h3>Batch Processing Configuration</h3>
<!-- PDF Selection -->
<div class="batch-settings-grid">
<div class="form-group">
<label for="batch_pdf_file">PDF Document</label>
<select id="batch_pdf_file" name="batch_pdf_file">
<option value="">Select a PDF file...</option>
</select>
<div id="batch-pdf-info" class="pdf-info hidden">
<span class="info-label">Total pages:</span>
<span id="total-pages">-</span>
</div>
</div>
<!-- Page Range Selection -->
<div class="form-group">
<label>Page Range</label>
<div class="page-range-controls">
<label class="radio-label">
<input type="radio" name="page_range" value="all" checked>
All pages
</label>
<label class="radio-label">
<input type="radio" name="page_range" value="range">
Custom range
</label>
</div>
<div id="custom-range" class="custom-range hidden">
<input type="number" id="start_page" min="1" placeholder="Start">
<span>to</span>
<input type="number" id="end_page" min="1" placeholder="End">
</div>
</div>
<!-- Processing Options -->
<div class="form-group">
<label>Processing Options</label>
<div class="processing-options">
<label>
<input type="checkbox" id="batch_adjust" checked>
Auto-adjust coordinates
</label>
<label>
<input type="checkbox" id="parallel_processing" checked>
Parallel processing
</label>
<label>
<input type="checkbox" id="generate_report" checked>
Generate summary report
</label>
</div>
</div>
</div>
</div>
<!-- Batch Progress Overview -->
<div class="batch-progress-panel hidden" id="batch-progress-panel">
<h3>Processing Progress</h3>
<!-- Overall Progress -->
<div class="overall-progress">
<div class="progress-header">
<span>Overall Progress</span>
<span id="overall-percentage">0%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="overall-progress-fill"></div>
</div>
<div class="progress-stats">
<span id="pages-completed">0</span> / <span id="pages-total">0</span> pages completed
<span class="separator"></span>
<span>Elapsed: <span id="elapsed-time">00:00</span></span>
<span class="separator"></span>
<span>ETA: <span id="eta-time">--:--</span></span>
</div>
</div>
<!-- Stage Progress -->
<div class="stage-progress">
<div class="stage-item">
<div class="stage-header">
<span class="stage-icon">📄</span>
<span class="stage-name">Analysis</span>
<span class="stage-percentage" id="analysis-percentage">0%</span>
</div>
<div class="progress-bar small">
<div class="progress-fill analysis" id="analysis-progress"></div>
</div>
</div>
<div class="stage-item">
<div class="stage-header">
<span class="stage-icon">🎨</span>
<span class="stage-name">Visualization</span>
<span class="stage-percentage" id="visualization-percentage">0%</span>
</div>
<div class="progress-bar small">
<div class="progress-fill visualization" id="visualization-progress"></div>
</div>
</div>
<div class="stage-item">
<div class="stage-header">
<span class="stage-icon">🖼️</span>
<span class="stage-name">Extraction</span>
<span class="stage-percentage" id="extraction-percentage">0%</span>
</div>
<div class="progress-bar small">
<div class="progress-fill extraction" id="extraction-progress"></div>
</div>
</div>
</div>
</div>
<!-- Control Buttons -->
<div class="batch-controls">
<button id="start-batch-btn" class="primary-btn" onclick="startBatchProcessing()">
Start Batch Processing
</button>
<button id="pause-batch-btn" class="secondary-btn hidden" onclick="pauseBatchProcessing()">
Pause
</button>
<button id="resume-batch-btn" class="secondary-btn hidden" onclick="resumeBatchProcessing()">
Resume
</button>
<button id="cancel-batch-btn" class="danger-btn hidden" onclick="cancelBatchProcessing()">
Cancel
</button>
</div>
<!-- Page Status Grid -->
<div class="page-status-grid hidden" id="page-status-grid">
<h3>Page Processing Status</h3>
<div class="page-grid" id="page-grid">
<!-- Page status items will be dynamically added here -->
</div>
</div>
<!-- Results Section -->
<div class="batch-results hidden" id="batch-results">
<h3>Batch Processing Results</h3>
<!-- Summary Statistics -->
<div class="results-summary">
<div class="stat-card">
<div class="stat-value" id="stat-success">0</div>
<div class="stat-label">Successful</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-failed">0</div>
<div class="stat-label">Failed</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-duration">00:00</div>
<div class="stat-label">Total Time</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-images">0</div>
<div class="stat-label">Images Extracted</div>
</div>
</div>
<!-- Action Buttons -->
<div class="results-actions">
<button class="action-btn" onclick="downloadResults()">
📥 Download All Results
</button>
<button class="action-btn" onclick="viewReport()">
📊 View Detailed Report
</button>
<button class="action-btn" onclick="openResultsFolder()">
📁 Open Results Folder
</button>
</div>
<!-- Failed Pages (if any) -->
<div class="failed-pages hidden" id="failed-pages">
<h4>Failed Pages</h4>
<div class="failed-list" id="failed-list">
<!-- Failed page items will be listed here -->
</div>
</div>
</div>
<!-- Console Output -->
<div class="console-output hidden" id="console-output">
<div class="console-header">
<h4>Processing Log</h4>
<button class="console-btn" onclick="clearConsole()">Clear</button>
<button class="console-btn" onclick="toggleAutoScroll()">
<span id="autoscroll-status">Auto-scroll: ON</span>
</button>
</div>
<div class="console-content" id="console-content">
<!-- Log messages will appear here -->
</div>
</div>
<!-- Environment Check -->
<div id="batch-environment-check" class="hidden">
<h3>System Environment</h3>
<div id="batch-env-details"></div>
</div>
</div>
<!-- Image Preview Modal -->
<div id="batch-image-modal" class="image-modal">
<div class="modal-content">
<span class="modal-close" onclick="closeBatchImageModal()">&times;</span>
<div class="modal-tabs">
<button class="modal-tab active" onclick="switchModalTab('visualization')">Visualization</button>
<button class="modal-tab" onclick="switchModalTab('extracted')">Extracted Images</button>
</div>
<div id="modal-visualization-content" class="modal-tab-content active">
<img id="modal-visualization-image" src="" alt="Page Visualization">
</div>
<div id="modal-extracted-content" class="modal-tab-content">
<div id="modal-extracted-gallery" class="modal-gallery">
<!-- Extracted images will be shown here -->
</div>
</div>
<div class="modal-info">
<p id="modal-page-info"></p>
</div>
</div>
</div>
<script src="static/batch-processor.js"></script>
</body>
</html>

View file

@ -0,0 +1,718 @@
// Batch Processor State Management
const batchState = {
isProcessing: false,
isPaused: false,
currentBatchId: null,
totalPages: 0,
processedPages: 0,
startTime: null,
pageStatuses: {},
results: {
successful: 0,
failed: 0,
totalImages: 0,
failedPages: []
},
autoScroll: true
};
// Timer for elapsed time
let elapsedTimer = null;
// Initialize on page load
window.addEventListener('DOMContentLoaded', function() {
loadPDFFiles();
setupEventListeners();
checkBatchEnvironment();
});
// Load available PDF files
function loadPDFFiles() {
fetch('/pdf-files')
.then(response => response.json())
.then(data => {
const select = document.getElementById('batch_pdf_file');
data.forEach(file => {
const option = document.createElement('option');
option.value = file;
option.textContent = file;
select.appendChild(option);
});
})
.catch(error => {
console.error('Error loading PDFs:', error);
addConsoleMessage('Error loading PDF files: ' + error.message, 'error');
});
}
// Setup event listeners
function setupEventListeners() {
// PDF selection change
document.getElementById('batch_pdf_file').addEventListener('change', function() {
if (this.value) {
fetchPDFInfo(this.value);
} else {
document.getElementById('batch-pdf-info').classList.add('hidden');
}
});
// Page range radio buttons
document.querySelectorAll('input[name="page_range"]').forEach(radio => {
radio.addEventListener('change', function() {
const customRange = document.getElementById('custom-range');
if (this.value === 'range') {
customRange.classList.remove('hidden');
} else {
customRange.classList.add('hidden');
}
});
});
}
// Fetch PDF information (page count)
function fetchPDFInfo(pdfFile) {
fetch(`/pdf-info/${encodeURIComponent(pdfFile)}`)
.then(response => response.json())
.then(data => {
document.getElementById('total-pages').textContent = data.pageCount;
document.getElementById('batch-pdf-info').classList.remove('hidden');
// Update max values for custom range
document.getElementById('start_page').max = data.pageCount;
document.getElementById('end_page').max = data.pageCount;
document.getElementById('end_page').value = data.pageCount;
})
.catch(error => {
console.error('Error fetching PDF info:', error);
addConsoleMessage('Error fetching PDF info: ' + error.message, 'error');
});
}
// Start batch processing
function startBatchProcessing() {
const pdfFile = document.getElementById('batch_pdf_file').value;
if (!pdfFile) {
alert('Please select a PDF file');
return;
}
// Get page range
const pageRangeType = document.querySelector('input[name="page_range"]:checked').value;
let startPage = 1;
let endPage = parseInt(document.getElementById('total-pages').textContent);
if (pageRangeType === 'range') {
startPage = parseInt(document.getElementById('start_page').value) || 1;
endPage = parseInt(document.getElementById('end_page').value) || endPage;
if (startPage > endPage) {
alert('Invalid page range: start page must be less than or equal to end page');
return;
}
}
// Reset state
resetBatchState();
batchState.isProcessing = true;
batchState.totalPages = endPage - startPage + 1;
batchState.startTime = Date.now();
// Update UI
updateUIForProcessing(true);
initializePageGrid(startPage, endPage);
// Start elapsed timer
startElapsedTimer();
// Show console
document.getElementById('console-output').classList.remove('hidden');
addConsoleMessage(`Starting batch processing for ${pdfFile} (pages ${startPage}-${endPage})`, 'info');
// Prepare request data
const formData = new FormData();
formData.append('pdf_file', pdfFile);
formData.append('start_page', startPage);
formData.append('end_page', endPage);
formData.append('adjust', document.getElementById('batch_adjust').checked);
formData.append('parallel', document.getElementById('parallel_processing').checked);
formData.append('generate_report', document.getElementById('generate_report').checked);
// Start batch processing
fetch('/run-batch-processor', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
batchState.currentBatchId = data.batch_id;
addConsoleMessage(`Batch processing started with ID: ${data.batch_id}`, 'success');
// Start polling for updates
pollBatchStatus();
} else {
throw new Error(data.error || 'Failed to start batch processing');
}
})
.catch(error => {
console.error('Error starting batch processing:', error);
addConsoleMessage('Error: ' + error.message, 'error');
finishBatchProcessing(false);
});
}
// Poll for batch status updates
function pollBatchStatus() {
if (!batchState.isProcessing || !batchState.currentBatchId) {
return;
}
fetch(`/batch-status/${batchState.currentBatchId}`)
.then(response => response.json())
.then(data => {
updateBatchProgress(data);
if (data.completed) {
finishBatchProcessing(true);
} else if (!batchState.isPaused) {
// Continue polling
setTimeout(pollBatchStatus, 1000);
}
})
.catch(error => {
console.error('Error polling batch status:', error);
if (batchState.isProcessing) {
setTimeout(pollBatchStatus, 2000); // Retry with longer delay
}
});
}
// Update batch progress
function updateBatchProgress(data) {
// Update overall progress
const percentage = Math.round((data.processed / data.total) * 100);
document.getElementById('overall-percentage').textContent = percentage + '%';
document.getElementById('overall-progress-fill').style.width = percentage + '%';
document.getElementById('pages-completed').textContent = data.processed;
document.getElementById('pages-total').textContent = data.total;
// Update stage progress
if (data.stages) {
updateStageProgress('analysis', data.stages.analysis);
updateStageProgress('visualization', data.stages.visualization);
updateStageProgress('extraction', data.stages.extraction);
}
// Update page statuses
if (data.page_statuses) {
for (const [pageNum, status] of Object.entries(data.page_statuses)) {
updatePageStatus(pageNum, status);
}
}
// Update results
if (data.results) {
batchState.results = data.results;
updateResultsSummary();
}
// Add log messages
if (data.logs && data.logs.length > 0) {
data.logs.forEach(log => {
addConsoleMessage(log.message, log.level);
});
}
// Update ETA
if (data.eta) {
document.getElementById('eta-time').textContent = formatTime(data.eta);
}
}
// Update stage progress
function updateStageProgress(stage, data) {
if (!data) return;
const percentage = Math.round((data.completed / data.total) * 100);
document.getElementById(`${stage}-percentage`).textContent = percentage + '%';
document.getElementById(`${stage}-progress`).style.width = percentage + '%';
}
// Initialize page grid
function initializePageGrid(startPage, endPage) {
const grid = document.getElementById('page-grid');
grid.innerHTML = '';
document.getElementById('batch-progress-panel').classList.remove('hidden');
document.getElementById('page-status-grid').classList.remove('hidden');
for (let i = startPage; i <= endPage; i++) {
const pageItem = createPageStatusItem(i);
grid.appendChild(pageItem);
batchState.pageStatuses[i] = 'pending';
}
}
// Create page status item
function createPageStatusItem(pageNum) {
const item = document.createElement('div');
item.className = 'page-status-item pending';
item.id = `page-status-${pageNum}`;
item.innerHTML = `
<div class="page-number">Page ${pageNum}</div>
<div class="page-status-icon"></div>
<div class="page-actions hidden">
<button class="mini-btn" onclick="viewPageResults(${pageNum})" title="View results">
👁
</button>
</div>
`;
item.onclick = () => viewPageResults(pageNum);
return item;
}
// Update page status
function updatePageStatus(pageNum, status) {
const item = document.getElementById(`page-status-${pageNum}`);
if (!item) return;
// Remove all status classes
item.classList.remove('pending', 'processing', 'completed', 'failed');
// Add new status class
item.classList.add(status.toLowerCase());
// Update icon
const icons = {
'pending': '⏳',
'processing': '🔄',
'completed': '✅',
'failed': '❌'
};
const iconElement = item.querySelector('.page-status-icon');
iconElement.textContent = icons[status.toLowerCase()] || '❓';
// Show actions for completed pages
if (status.toLowerCase() === 'completed') {
item.querySelector('.page-actions').classList.remove('hidden');
}
batchState.pageStatuses[pageNum] = status.toLowerCase();
}
// View page results
function viewPageResults(pageNum) {
// Check if we have a current batch ID
if (!batchState.currentBatchId) {
addConsoleMessage('No batch ID available', 'error');
return;
}
// Open modal with page visualization and extracted images
const modal = document.getElementById('batch-image-modal');
modal.classList.add('active');
// Load visualization from the batch report directory
const vizImage = document.getElementById('modal-visualization-image');
vizImage.src = `/batch-report-image/${batchState.currentBatchId}/visualization_page_${pageNum}.png?t=${Date.now()}`;
// Handle loading errors
vizImage.onerror = function() {
// Try the regular results directory as fallback
this.src = `/results/visualization_page_${pageNum}.png?t=${Date.now()}`;
this.onerror = function() {
this.alt = 'Visualization not found';
console.error('Failed to load visualization');
};
};
// Load extracted images
loadExtractedImagesForPage(pageNum);
// Update page info
document.getElementById('modal-page-info').textContent = `Page ${pageNum} Results`;
}
// Load extracted images for a specific page
function loadExtractedImagesForPage(pageNum) {
const gallery = document.getElementById('modal-extracted-gallery');
gallery.innerHTML = '<div class="loader"></div> Loading extracted images...';
// Fetch extracted images for this page
fetch(`/results/pictures_page_${pageNum}/index.html`)
.then(response => {
if (!response.ok) throw new Error('No extracted images');
return response.text();
})
.then(html => {
// Parse and display images
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const images = doc.querySelectorAll('.picture-card img');
if (images.length === 0) {
gallery.innerHTML = '<p class="no-images">No images extracted from this page</p>';
return;
}
let galleryHTML = '';
images.forEach(img => {
const src = img.getAttribute('src');
galleryHTML += `
<div class="modal-gallery-item">
<img src="/results/pictures_page_${pageNum}/${src}"
alt="Extracted image"
onclick="window.open('/results/pictures_page_${pageNum}/${src}', '_blank')">
</div>
`;
});
gallery.innerHTML = galleryHTML;
})
.catch(error => {
gallery.innerHTML = '<p class="no-images">No extracted images available</p>';
});
}
// Switch modal tabs
function switchModalTab(tabName) {
// Update tab buttons
document.querySelectorAll('.modal-tab').forEach(tab => {
tab.classList.remove('active');
});
event.target.classList.add('active');
// Update content
document.querySelectorAll('.modal-tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`modal-${tabName}-content`).classList.add('active');
}
// Close modal
function closeBatchImageModal() {
document.getElementById('batch-image-modal').classList.remove('active');
}
// Pause batch processing
function pauseBatchProcessing() {
if (!batchState.isProcessing || batchState.isPaused) return;
batchState.isPaused = true;
fetch(`/pause-batch/${batchState.currentBatchId}`, { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
addConsoleMessage('Batch processing paused', 'info');
document.getElementById('pause-batch-btn').classList.add('hidden');
document.getElementById('resume-batch-btn').classList.remove('hidden');
}
})
.catch(error => {
console.error('Error pausing batch:', error);
addConsoleMessage('Error pausing batch: ' + error.message, 'error');
});
}
// Resume batch processing
function resumeBatchProcessing() {
if (!batchState.isProcessing || !batchState.isPaused) return;
batchState.isPaused = false;
fetch(`/resume-batch/${batchState.currentBatchId}`, { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
addConsoleMessage('Batch processing resumed', 'info');
document.getElementById('resume-batch-btn').classList.add('hidden');
document.getElementById('pause-batch-btn').classList.remove('hidden');
// Resume polling
pollBatchStatus();
}
})
.catch(error => {
console.error('Error resuming batch:', error);
addConsoleMessage('Error resuming batch: ' + error.message, 'error');
});
}
// Cancel batch processing
function cancelBatchProcessing() {
if (!batchState.isProcessing) return;
if (!confirm('Are you sure you want to cancel the batch processing?')) {
return;
}
fetch(`/cancel-batch/${batchState.currentBatchId}`, { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
addConsoleMessage('Batch processing cancelled', 'warning');
finishBatchProcessing(false);
}
})
.catch(error => {
console.error('Error cancelling batch:', error);
addConsoleMessage('Error cancelling batch: ' + error.message, 'error');
});
}
// Finish batch processing
function finishBatchProcessing(success) {
batchState.isProcessing = false;
stopElapsedTimer();
// Update UI
updateUIForProcessing(false);
// Show results
document.getElementById('batch-results').classList.remove('hidden');
updateResultsSummary();
if (success) {
addConsoleMessage('Batch processing completed successfully!', 'success');
} else {
addConsoleMessage('Batch processing stopped', 'warning');
}
// Update failed pages list if any
if (batchState.results.failedPages.length > 0) {
showFailedPages();
}
}
// Update results summary
function updateResultsSummary() {
document.getElementById('stat-success').textContent = batchState.results.successful;
document.getElementById('stat-failed').textContent = batchState.results.failed;
document.getElementById('stat-duration').textContent = formatTime(Date.now() - batchState.startTime);
document.getElementById('stat-images').textContent = batchState.results.totalImages;
}
// Show failed pages
function showFailedPages() {
const failedSection = document.getElementById('failed-pages');
const failedList = document.getElementById('failed-list');
failedSection.classList.remove('hidden');
failedList.innerHTML = '';
batchState.results.failedPages.forEach(page => {
const item = document.createElement('div');
item.className = 'failed-item';
item.innerHTML = `
<span>Page ${page.pageNum}</span>
<span class="failed-reason">${page.reason}</span>
<button class="retry-btn" onclick="retryPage(${page.pageNum})">Retry</button>
`;
failedList.appendChild(item);
});
}
// Retry failed page
function retryPage(pageNum) {
addConsoleMessage(`Retrying page ${pageNum}...`, 'info');
const formData = new FormData();
formData.append('pdf_file', document.getElementById('batch_pdf_file').value);
formData.append('page_num', pageNum);
formData.append('adjust', document.getElementById('batch_adjust').checked);
// Update page status
updatePageStatus(pageNum, 'processing');
fetch('/retry-page', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
updatePageStatus(pageNum, 'completed');
addConsoleMessage(`Page ${pageNum} processed successfully`, 'success');
// Update results
batchState.results.successful++;
batchState.results.failed--;
batchState.results.failedPages = batchState.results.failedPages.filter(p => p.pageNum !== pageNum);
updateResultsSummary();
if (batchState.results.failedPages.length === 0) {
document.getElementById('failed-pages').classList.add('hidden');
}
} else {
updatePageStatus(pageNum, 'failed');
addConsoleMessage(`Failed to process page ${pageNum}: ${data.error}`, 'error');
}
})
.catch(error => {
updatePageStatus(pageNum, 'failed');
addConsoleMessage(`Error retrying page ${pageNum}: ${error.message}`, 'error');
});
}
// Download all results
function downloadResults() {
addConsoleMessage('Preparing results for download...', 'info');
fetch(`/download-batch-results/${batchState.currentBatchId}`)
.then(response => {
if (!response.ok) throw new Error('Failed to prepare download');
return response.blob();
})
.then(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `doctags_batch_results_${new Date().toISOString().split('T')[0]}.zip`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
addConsoleMessage('Download started', 'success');
})
.catch(error => {
addConsoleMessage('Error downloading results: ' + error.message, 'error');
});
}
// View detailed report
function viewReport() {
window.open(`/batch-report/${batchState.currentBatchId}`, '_blank');
}
// Open results folder
function openResultsFolder() {
addConsoleMessage('Opening results folder...', 'info');
fetch('/open-results-folder', { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.success) {
addConsoleMessage('Results folder opened', 'success');
} else {
addConsoleMessage('Could not open results folder: ' + data.error, 'error');
}
})
.catch(error => {
addConsoleMessage('Error: ' + error.message, 'error');
});
}
// Console functions
function addConsoleMessage(message, level = 'info') {
const console = document.getElementById('console-content');
const timestamp = new Date().toLocaleTimeString();
const messageDiv = document.createElement('div');
messageDiv.className = `console-message ${level}`;
messageDiv.innerHTML = `<span class="timestamp">[${timestamp}]</span> ${message}`;
console.appendChild(messageDiv);
if (batchState.autoScroll) {
console.scrollTop = console.scrollHeight;
}
}
function clearConsole() {
document.getElementById('console-content').innerHTML = '';
}
function toggleAutoScroll() {
batchState.autoScroll = !batchState.autoScroll;
document.getElementById('autoscroll-status').textContent =
`Auto-scroll: ${batchState.autoScroll ? 'ON' : 'OFF'}`;
}
// Timer functions
function startElapsedTimer() {
elapsedTimer = setInterval(() => {
const elapsed = Date.now() - batchState.startTime;
document.getElementById('elapsed-time').textContent = formatTime(elapsed);
}, 1000);
}
function stopElapsedTimer() {
if (elapsedTimer) {
clearInterval(elapsedTimer);
elapsedTimer = null;
}
}
// Helper functions
function formatTime(milliseconds) {
const seconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
} else {
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
}
}
function resetBatchState() {
batchState.isProcessing = false;
batchState.isPaused = false;
batchState.currentBatchId = null;
batchState.totalPages = 0;
batchState.processedPages = 0;
batchState.startTime = null;
batchState.pageStatuses = {};
batchState.results = {
successful: 0,
failed: 0,
totalImages: 0,
failedPages: []
};
}
function updateUIForProcessing(isProcessing) {
// Toggle button visibility
document.getElementById('start-batch-btn').classList.toggle('hidden', isProcessing);
document.getElementById('pause-batch-btn').classList.toggle('hidden', !isProcessing);
document.getElementById('cancel-batch-btn').classList.toggle('hidden', !isProcessing);
// Disable form inputs during processing
const inputs = document.querySelectorAll('.settings-panel input, .settings-panel select');
inputs.forEach(input => {
input.disabled = isProcessing;
});
}
// Check batch environment
function checkBatchEnvironment() {
fetch('/check-environment')
.then(response => response.json())
.then(data => {
const envDiv = document.getElementById('batch-environment-check');
const envDetails = document.getElementById('batch-env-details');
if (data.missing_scripts.length > 0 || data.pdf_files.length === 0) {
envDiv.classList.remove('hidden');
let html = '<ul>';
if (data.missing_scripts.length > 0) {
html += '<li class="env-error">Missing scripts: ' + data.missing_scripts.join(', ') + '</li>';
}
if (data.pdf_files.length === 0) {
html += '<li class="env-error">No PDF files found in working directory</li>';
}
html += '</ul>';
envDetails.innerHTML = html;
}
})
.catch(error => {
console.error('Error checking environment:', error);
});
}

View file

@ -0,0 +1,616 @@
/* Batch Processor Specific Styles */
/* Navigation link */
.nav-link {
text-align: center;
margin-bottom: var(--spacing-lg);
}
.nav-link a {
color: var(--primary-medium);
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.nav-link a:hover {
color: var(--primary-dark);
}
/* Batch Settings Grid */
.batch-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--spacing-xl);
margin-top: var(--spacing-lg);
}
.pdf-info {
margin-top: var(--spacing-sm);
padding: var(--spacing-sm);
background: var(--surface-light);
border-radius: var(--radius-sm);
font-size: 0.9rem;
}
.info-label {
color: var(--text-medium);
font-weight: 500;
}
/* Page Range Controls */
.page-range-controls {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.radio-label {
display: flex;
align-items: center;
cursor: pointer;
font-weight: 400;
color: var(--text-medium);
}
.radio-label input[type="radio"] {
margin-right: var(--spacing-xs);
}
.custom-range {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-top: var(--spacing-sm);
}
.custom-range input {
width: 80px;
}
/* Processing Options */
.processing-options {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
/* Batch Progress Panel */
.batch-progress-panel {
background: var(--surface-white);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
margin: var(--spacing-xl) 0;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-light);
}
/* Overall Progress */
.overall-progress {
margin-bottom: var(--spacing-xl);
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-sm);
}
.progress-header span:first-child {
font-weight: 600;
color: var(--primary-dark);
}
#overall-percentage {
font-weight: 600;
color: var(--primary-medium);
font-size: 1.125rem;
}
.progress-bar {
height: 24px;
background: var(--surface-light);
border-radius: 12px;
overflow: hidden;
position: relative;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
}
.progress-bar.small {
height: 16px;
border-radius: 8px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary-medium), var(--primary-dark));
width: 0%;
transition: width 0.3s ease;
position: relative;
overflow: hidden;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: linear-gradient(
45deg,
rgba(255, 255, 255, 0.2) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.2) 50%,
rgba(255, 255, 255, 0.2) 75%,
transparent 75%,
transparent
);
background-size: 30px 30px;
animation: progress-stripes 1s linear infinite;
}
@keyframes progress-stripes {
0% { background-position: 0 0; }
100% { background-position: 30px 0; }
}
.progress-stats {
margin-top: var(--spacing-sm);
font-size: 0.875rem;
color: var(--text-medium);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.separator {
color: var(--border-light);
}
/* Stage Progress */
.stage-progress {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-lg);
}
.stage-item {
padding: var(--spacing-md);
background: var(--surface-light);
border-radius: var(--radius-md);
}
.stage-header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
.stage-icon {
font-size: 1.25rem;
}
.stage-name {
flex: 1;
font-weight: 500;
color: var(--text-dark);
}
.stage-percentage {
font-weight: 600;
color: var(--text-medium);
font-size: 0.875rem;
}
/* Stage-specific progress colors */
.progress-fill.analysis {
background: linear-gradient(90deg, #3498db, #2980b9);
}
.progress-fill.visualization {
background: linear-gradient(90deg, #e74c3c, #c0392b);
}
.progress-fill.extraction {
background: linear-gradient(90deg, #2ecc71, #27ae60);
}
/* Batch Controls */
.batch-controls {
display: flex;
justify-content: center;
gap: var(--spacing-md);
margin: var(--spacing-xl) 0;
}
.primary-btn {
background: var(--primary-medium);
color: white;
padding: var(--spacing-md) var(--spacing-xl);
font-weight: 600;
}
.primary-btn:hover {
background: var(--primary-dark);
}
.secondary-btn {
background: var(--accent-amber);
color: var(--primary-dark);
}
.secondary-btn:hover {
background: #f0c674;
}
.danger-btn {
background: var(--error);
color: white;
}
.danger-btn:hover {
background: #c0392b;
}
/* Page Status Grid */
.page-status-grid {
background: var(--surface-white);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
margin: var(--spacing-xl) 0;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-light);
}
.page-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: var(--spacing-sm);
margin-top: var(--spacing-lg);
}
.page-status-item {
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--surface-light);
border-radius: var(--radius-md);
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.page-status-item:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.page-status-item.pending {
border-color: var(--border-light);
}
.page-status-item.processing {
border-color: var(--accent-amber);
background: linear-gradient(135deg, var(--accent-amber) 0%, transparent 100%);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.page-status-item.completed {
border-color: var(--success);
background: var(--accent-mint);
}
.page-status-item.failed {
border-color: var(--error);
background: var(--accent-rose);
}
.page-number {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-dark);
}
.page-status-icon {
font-size: 1.5rem;
margin: var(--spacing-xs) 0;
}
.page-actions {
position: absolute;
bottom: 4px;
right: 4px;
}
.mini-btn {
padding: 2px 4px;
font-size: 0.75rem;
background: var(--surface-white);
border: 1px solid var(--border-light);
border-radius: 4px;
cursor: pointer;
}
/* Batch Results */
.batch-results {
background: var(--surface-white);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
margin: var(--spacing-xl) 0;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-light);
}
.results-summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-lg);
margin: var(--spacing-lg) 0;
}
.stat-card {
text-align: center;
padding: var(--spacing-lg);
background: var(--surface-light);
border-radius: var(--radius-md);
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--primary-dark);
margin-bottom: var(--spacing-xs);
}
.stat-label {
font-size: 0.875rem;
color: var(--text-medium);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.results-actions {
display: flex;
justify-content: center;
gap: var(--spacing-md);
margin: var(--spacing-xl) 0;
}
.action-btn {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-lg);
background: var(--primary-medium);
color: white;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
}
.action-btn:hover {
background: var(--primary-dark);
transform: translateY(-1px);
}
/* Failed Pages */
.failed-pages {
margin-top: var(--spacing-xl);
padding: var(--spacing-lg);
background: var(--accent-rose);
border-radius: var(--radius-md);
border: 1px solid var(--error);
}
.failed-pages h4 {
color: var(--error);
margin-top: 0;
}
.failed-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.failed-item {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-sm);
background: var(--surface-white);
border-radius: var(--radius-sm);
}
.failed-reason {
flex: 1;
font-size: 0.875rem;
color: var(--text-medium);
}
.retry-btn {
padding: var(--spacing-xs) var(--spacing-md);
background: var(--warning);
color: white;
font-size: 0.875rem;
}
/* Console Output */
.console-output {
background: var(--text-dark);
border-radius: var(--radius-lg);
margin: var(--spacing-xl) 0;
box-shadow: var(--shadow-md);
overflow: hidden;
}
.console-header {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md) var(--spacing-lg);
background: rgba(0, 0, 0, 0.3);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.console-header h4 {
flex: 1;
margin: 0;
color: white;
font-size: 1rem;
}
.console-btn {
padding: var(--spacing-xs) var(--spacing-md);
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
font-size: 0.8rem;
}
.console-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.console-content {
height: 300px;
overflow-y: auto;
padding: var(--spacing-md);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.8rem;
}
.console-message {
margin-bottom: var(--spacing-xs);
line-height: 1.4;
}
.console-message.info {
color: #64b5f6;
}
.console-message.success {
color: #81c784;
}
.console-message.warning {
color: #ffb74d;
}
.console-message.error {
color: #e57373;
}
.timestamp {
color: #90a4ae;
margin-right: var(--spacing-sm);
}
/* Modal Enhancements */
.modal-tabs {
display: flex;
border-bottom: 1px solid var(--border-light);
background: var(--surface-light);
}
.modal-tab {
flex: 1;
padding: var(--spacing-md);
background: transparent;
border: none;
cursor: pointer;
font-weight: 500;
color: var(--text-medium);
transition: all 0.2s ease;
}
.modal-tab.active {
color: var(--primary-dark);
border-bottom: 2px solid var(--primary-medium);
}
.modal-tab-content {
display: none;
padding: var(--spacing-lg);
}
.modal-tab-content.active {
display: block;
}
.modal-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--spacing-md);
max-height: 400px;
overflow-y: auto;
}
.modal-gallery-item {
cursor: pointer;
transition: transform 0.2s ease;
}
.modal-gallery-item:hover {
transform: scale(1.05);
}
.modal-gallery-item img {
width: 100%;
height: 150px;
object-fit: cover;
border-radius: var(--radius-sm);
}
/* Responsive Design */
@media (max-width: 1024px) {
.batch-settings-grid {
grid-template-columns: 1fr;
}
.stage-progress {
grid-template-columns: 1fr;
gap: var(--spacing-md);
}
.results-summary {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.page-grid {
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
}
.results-actions {
flex-direction: column;
}
.action-btn {
width: 100%;
justify-content: center;
}
.batch-controls {
flex-wrap: wrap;
}
.console-content {
height: 200px;
}
}