Spaces:
Running
Running
| {% extends "base.html" %} | |
| {% block title %}Question Table Generator{% endblock %} | |
| {% block head %} | |
| <style> | |
| body { | |
| padding-bottom: 100px; /* Extra space at bottom for device chrome */ | |
| } | |
| .qtab-container { | |
| max-width: 1400px; | |
| margin: 0 auto; | |
| padding: 20px; | |
| min-height: calc(100vh - 150px); /* Dynamic height with bottom space */ | |
| } | |
| .upload-section { | |
| background: #212529; | |
| padding: 30px; | |
| border-radius: 12px; | |
| margin-bottom: 30px; | |
| border: 2px dashed #495057; | |
| text-align: center; | |
| transition: all 0.3s; | |
| } | |
| .upload-section.dragover { | |
| border-color: #0d6efd; | |
| background: #1a1d20; | |
| } | |
| .upload-section.paste-active { | |
| border-color: #28a745; | |
| background: #1a2520; | |
| } | |
| .folder-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); | |
| gap: 15px; | |
| margin-bottom: 30px; | |
| } | |
| .folder-card { | |
| background: #212529; | |
| padding: 20px; | |
| border-radius: 8px; | |
| text-align: center; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| border: 1px solid #495057; | |
| position: relative; | |
| } | |
| .folder-card:hover { | |
| background: #2c3136; | |
| border-color: #0d6efd; | |
| transform: translateY(-2px); | |
| } | |
| .folder-card i { | |
| font-size: 48px; | |
| color: #0d6efd; | |
| margin-bottom: 10px; | |
| } | |
| .images-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); | |
| gap: 20px; | |
| } | |
| .image-card { | |
| background: #212529; | |
| border-radius: 12px; | |
| overflow: hidden; | |
| border: 1px solid #495057; | |
| transition: all 0.3s; | |
| position: relative; | |
| } | |
| .image-card.selected { | |
| border-color: #0d6efd; | |
| box-shadow: 0 0 10px rgba(13, 110, 253, 0.5); | |
| } | |
| .image-card:hover { | |
| border-color: #0d6efd; | |
| transform: translateY(-2px); | |
| } | |
| .image-card .selection-checkbox { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| z-index: 10; | |
| width: 20px; | |
| height: 20px; | |
| cursor: pointer; | |
| } | |
| .image-preview { | |
| width: 100%; | |
| height: 200px; | |
| object-fit: cover; | |
| background: #343a40; | |
| } | |
| .image-content { | |
| padding: 15px; | |
| } | |
| .result-section { | |
| margin-top: 10px; | |
| padding: 10px; | |
| background: #343a40; | |
| border-radius: 6px; | |
| max-height: 300px; | |
| overflow-y: auto; | |
| } | |
| .result-success { | |
| border-left: 3px solid #28a745; | |
| } | |
| .result-error { | |
| border-left: 3px solid #dc3545; | |
| } | |
| .section-group { | |
| margin-bottom: 15px; | |
| } | |
| .section-title { | |
| font-weight: bold; | |
| color: #0d6efd; | |
| margin-bottom: 5px; | |
| } | |
| .qa-pair { | |
| display: flex; | |
| justify-content: space-between; | |
| padding: 5px; | |
| margin: 2px 0; | |
| background: #212529; | |
| border-radius: 4px; | |
| } | |
| .upload-btn { | |
| display: inline-block; | |
| padding: 12px 30px; | |
| background: #0d6efd; | |
| color: white; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| } | |
| .upload-btn:hover { | |
| background: #0b5ed7; | |
| transform: scale(1.05); | |
| } | |
| #imageInput { | |
| display: none; | |
| } | |
| .loading-spinner { | |
| display: none; | |
| margin: 20px auto; | |
| } | |
| .loading-spinner.active { | |
| display: block; | |
| } | |
| /* Paste indicator */ | |
| .paste-hint { | |
| font-size: 0.9em; | |
| color: #6c757d; | |
| margin-top: 10px; | |
| } | |
| .paste-hint .kbd { | |
| background: #343a40; | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| font-family: monospace; | |
| } | |
| </style> | |
| {% endblock %} | |
| {% block content %} | |
| <div class="qtab-container"> | |
| <!-- Toolbar --> | |
| <div class="d-flex justify-content-between align-items-center mb-3"> | |
| <div> | |
| <h2 class="mb-0"><i class="bi bi-table"></i> Question Table Generator</h2> | |
| <nav aria-label="breadcrumb"> | |
| <ol class="breadcrumb mb-0"> | |
| <li class="breadcrumb-item"><a href="{{ url_for('qtab.qtab_list') }}">Home</a></li> | |
| {% for crumb in breadcrumbs %} | |
| <li class="breadcrumb-item"><a href="{{ url_for('qtab.qtab_list', folder_path=crumb.path) }}">{{ crumb.name }}</a></li> | |
| {% endfor %} | |
| </ol> | |
| </nav> | |
| </div> | |
| <div class="btn-group"> | |
| <button type="button" class="btn btn-primary" onclick="showCreateFolderModal()"> | |
| <i class="bi bi-folder-plus"></i> New Folder | |
| </button> | |
| <button type="button" class="btn btn-warning text-dark" id="moveBtn" disabled data-bs-toggle="modal" data-bs-target="#moveModal"> | |
| <i class="bi bi-arrow-right-square"></i> Move Selected | |
| </button> | |
| <button type="button" class="btn btn-danger" id="deleteBtn" disabled onclick="deleteSelectedImages()"> | |
| <i class="bi bi-trash"></i> Delete Selected | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Flash Messages --> | |
| {% with messages = get_flashed_messages(with_categories=true) %} | |
| {% if messages %} | |
| {% for category, message in messages %} | |
| <div class="alert alert-{{ category }} alert-dismissible fade show" role="alert"> | |
| {{ message }} | |
| <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> | |
| </div> | |
| {% endfor %} | |
| {% endif %} | |
| {% endwith %} | |
| <!-- Upload Section --> | |
| <div class="upload-section" id="uploadSection"> | |
| <i class="fas fa-cloud-upload-alt fa-3x mb-3"></i> | |
| <h4>Upload Image for Question Extraction</h4> | |
| <p class="text-muted">Drag and drop an image here, or click to select</p> | |
| <label for="imageInput" class="upload-btn"> | |
| <i class="fas fa-upload"></i> Choose Image | |
| </label> | |
| <input type="file" id="imageInput" accept="image/*"> | |
| <div class="paste-hint"> | |
| <span class="kbd">Ctrl+V</span> to paste image from clipboard | |
| </div> | |
| <div class="spinner-border loading-spinner" role="status"> | |
| <span class="visually-hidden">Processing...</span> | |
| </div> | |
| </div> | |
| <!-- Folders Section --> | |
| {% if subfolders %} | |
| <h3 class="mb-3"><i class="fas fa-folder"></i> Folders</h3> | |
| <div class="folder-grid"> | |
| {% for folder in subfolders %} | |
| <div class="folder-card" onclick="navigateToFolder('{{ folder.name }}')"> | |
| <i class="fas fa-folder"></i> | |
| <div>{{ folder.name }}</div> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| {% endif %} | |
| <!-- Images Section --> | |
| {% if images %} | |
| <h3 class="mb-3"><i class="fas fa-images"></i> Processed Images</h3> | |
| <div class="images-grid" id="imagesGrid"> | |
| {% for image in images %} | |
| <div class="image-card" data-image-id="{{ image.id }}"> | |
| <input type="checkbox" class="form-check-input selection-checkbox image-checkbox" value="{{ image.id }}"> | |
| <img src="{{ url_for('qtab.serve_qtab_image', filename=image.filename) }}" | |
| class="image-preview" | |
| alt="{{ image.original_name }}" | |
| onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%22100%22%3E%3Crect fill=%22%23343a40%22 width=%22100%22 height=%22100%22/%3E%3Ctext fill=%22%236c757d%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22%3ENo Preview%3C/text%3E%3C/svg%3E'"> | |
| <div class="image-content"> | |
| <div class="d-flex justify-content-between align-items-start"> | |
| <h6 class="mb-1">{{ image.original_name }}</h6> | |
| <button class="btn btn-sm btn-link p-0" onclick="renameImage({{ image.id }}, '{{ image.original_name }}')" title="Rename"> | |
| <i class="bi bi-pencil"></i> | |
| </button> | |
| </div> | |
| <small class="text-muted">{{ image.created_at }}</small> | |
| {% if image.result_json %} | |
| <div class="result-section result-{{ image.status }}"> | |
| <div id="result-{{ image.id }}"></div> | |
| </div> | |
| <script> | |
| </script> | |
| {% endif %} | |
| <div class="d-flex gap-2 mt-2"> | |
| <a href="{{ url_for('qtab.qtab_exam_mode', image_id=image.id) }}" class="btn btn-sm btn-primary flex-grow-1"> | |
| <i class="fas fa-graduation-cap"></i> Exam Mode | |
| </a> | |
| <button class="btn btn-sm btn-danger" onclick="deleteImage({{ image.id }})"> | |
| <i class="fas fa-trash"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {% endfor %} | |
| </div> | |
| {% elif not subfolders %} | |
| <div class="alert alert-info"> | |
| <i class="fas fa-info-circle"></i> No images uploaded yet. Upload an image to get started! | |
| </div> | |
| {% endif %} | |
| </div> | |
| <!-- Create Folder Modal --> | |
| <div class="modal fade" id="createFolderModal" tabindex="-1"> | |
| <div class="modal-dialog"> | |
| <div class="modal-content bg-dark"> | |
| <div class="modal-header"> | |
| <h5 class="modal-title">Create New Folder</h5> | |
| <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button> | |
| </div> | |
| <div class="modal-body"> | |
| <div class="mb-3"> | |
| <label for="folderName" class="form-label">Folder Name</label> | |
| <input type="text" class="form-control" id="folderName" placeholder="Enter folder name"> | |
| </div> | |
| </div> | |
| <div class="modal-footer"> | |
| <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> | |
| <button type="button" class="btn btn-primary" onclick="createFolder()">Create</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Move Modal --> | |
| <div class="modal fade" id="moveModal" tabindex="-1"> | |
| <div class="modal-dialog"> | |
| <div class="modal-content bg-dark"> | |
| <div class="modal-header"> | |
| <h5 class="modal-title">Move Images</h5> | |
| <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button> | |
| </div> | |
| <div class="modal-body"> | |
| <div class="mb-3"> | |
| <label for="targetFolder" class="form-label">Select Destination Folder</label> | |
| <select class="form-select" id="targetFolder"> | |
| <option value="">Root</option> | |
| {% if folder_tree %} | |
| {% for folder in folder_tree %} | |
| <option value="{{ folder.id }}">{{ folder.path }}</option> | |
| {% endfor %} | |
| {% endif %} | |
| </select> | |
| </div> | |
| </div> | |
| <div class="modal-footer"> | |
| <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> | |
| <button type="button" class="btn btn-warning text-dark" onclick="moveSelectedImages()">Move</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Rename Image Modal --> | |
| <div class="modal fade" id="renameModal" tabindex="-1"> | |
| <div class="modal-dialog"> | |
| <div class="modal-content bg-dark"> | |
| <div class="modal-header"> | |
| <h5 class="modal-title">Rename Image</h5> | |
| <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button> | |
| </div> | |
| <div class="modal-body"> | |
| <div class="mb-3"> | |
| <label for="newImageName" class="form-label">New Name</label> | |
| <input type="text" class="form-control" id="newImageName" placeholder="Enter new name"> | |
| <input type="hidden" id="renameImageId"> | |
| </div> | |
| </div> | |
| <div class="modal-footer"> | |
| <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> | |
| <button type="button" class="btn btn-primary" onclick="submitRename()">Rename</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const currentFolderId = {{ current_folder_id | tojson }}; | |
| const currentFolderPath = '{{ breadcrumbs[-1].path if breadcrumbs else "" }}'; | |
| // File upload handling | |
| const uploadSection = document.getElementById('uploadSection'); | |
| const imageInput = document.getElementById('imageInput'); | |
| const loadingSpinner = document.querySelector('.loading-spinner'); | |
| imageInput.addEventListener('change', handleFileSelect); | |
| uploadSection.addEventListener('dragover', (e) => { | |
| e.preventDefault(); | |
| uploadSection.classList.add('dragover'); | |
| }); | |
| uploadSection.addEventListener('dragleave', () => { | |
| uploadSection.classList.remove('dragover'); | |
| }); | |
| uploadSection.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| uploadSection.classList.remove('dragover'); | |
| const files = e.dataTransfer.files; | |
| if (files.length > 0) { | |
| handleFile(files[0]); | |
| } | |
| }); | |
| // Ctrl+V paste image support | |
| document.addEventListener('paste', async (e) => { | |
| const items = e.clipboardData.items; | |
| for (let i = 0; i < items.length; i++) { | |
| if (items[i].type.indexOf('image') !== -1) { | |
| e.preventDefault(); | |
| const blob = items[i].getAsFile(); | |
| // Visual feedback | |
| uploadSection.classList.add('paste-active'); | |
| setTimeout(() => uploadSection.classList.remove('paste-active'), 300); | |
| handleFile(blob); | |
| break; | |
| } | |
| } | |
| }); | |
| function handleFileSelect(e) { | |
| const file = e.target.files[0]; | |
| if (file) { | |
| handleFile(file); | |
| } | |
| } | |
| async function handleFile(file) { | |
| if (!file.type.startsWith('image/')) { | |
| alert('Please select an image file'); | |
| return; | |
| } | |
| const formData = new FormData(); | |
| formData.append('image', file); | |
| formData.append('folder_id', currentFolderId || ''); | |
| loadingSpinner.classList.add('active'); | |
| try { | |
| const response = await fetch('{{ url_for("qtab.qtab_upload") }}', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| const result = await response.json(); | |
| if (result.status === 'success' || result.status === 'error') { | |
| // Reload page to show new image | |
| window.location.reload(); | |
| } else { | |
| alert('Upload failed: ' + (result.message || 'Unknown error')); | |
| } | |
| } catch (error) { | |
| console.error('Upload error:', error); | |
| alert('Upload failed: ' + error.message); | |
| } finally { | |
| loadingSpinner.classList.remove('active'); | |
| imageInput.value = ''; | |
| } | |
| } | |
| // Selection handling | |
| const imageCheckboxes = document.querySelectorAll('.image-checkbox'); | |
| const moveBtn = document.getElementById('moveBtn'); | |
| const deleteBtn = document.getElementById('deleteBtn'); | |
| imageCheckboxes.forEach(checkbox => { | |
| checkbox.addEventListener('change', updateSelectionButtons); | |
| }); | |
| function updateSelectionButtons() { | |
| const selectedCount = document.querySelectorAll('.image-checkbox:checked').length; | |
| moveBtn.disabled = selectedCount === 0; | |
| deleteBtn.disabled = selectedCount === 0; | |
| // Update card styling | |
| document.querySelectorAll('.image-card').forEach(card => { | |
| const checkbox = card.querySelector('.image-checkbox'); | |
| if (checkbox && checkbox.checked) { | |
| card.classList.add('selected'); | |
| } else { | |
| card.classList.remove('selected'); | |
| } | |
| }); | |
| } | |
| function navigateToFolder(folderName) { | |
| const newPath = currentFolderPath ? `${currentFolderPath}/${folderName}` : folderName; | |
| window.location.href = `{{ url_for('qtab.qtab_list') }}/${newPath}`; | |
| } | |
| function showCreateFolderModal() { | |
| const modal = new bootstrap.Modal(document.getElementById('createFolderModal')); | |
| modal.show(); | |
| } | |
| async function createFolder() { | |
| const folderName = document.getElementById('folderName').value.trim(); | |
| if (!folderName) { | |
| alert('Please enter a folder name'); | |
| return; | |
| } | |
| try { | |
| const response = await fetch('{{ url_for("qtab.qtab_create_folder") }}', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| name: folderName, | |
| parent_id: currentFolderId | |
| }) | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| window.location.reload(); | |
| } else { | |
| alert('Error: ' + (result.error || 'Failed to create folder')); | |
| } | |
| } catch (error) { | |
| console.error('Error creating folder:', error); | |
| alert('Error: ' + error.message); | |
| } | |
| } | |
| // Rename image | |
| function renameImage(imageId, currentName) { | |
| document.getElementById('renameImageId').value = imageId; | |
| document.getElementById('newImageName').value = currentName; | |
| const modal = new bootstrap.Modal(document.getElementById('renameModal')); | |
| modal.show(); | |
| } | |
| async function submitRename() { | |
| const imageId = document.getElementById('renameImageId').value; | |
| const newName = document.getElementById('newImageName').value.trim(); | |
| if (!newName) { | |
| alert('Please enter a name'); | |
| return; | |
| } | |
| try { | |
| const response = await fetch('{{ url_for("qtab.qtab_rename_image", image_id=0) }}'.replace('0', imageId), { | |
| method: 'PUT', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ name: newName }) | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| window.location.reload(); | |
| } else { | |
| alert('Error: ' + (result.error || 'Failed to rename image')); | |
| } | |
| } catch (error) { | |
| console.error('Error renaming image:', error); | |
| alert('Error: ' + error.message); | |
| } | |
| } | |
| // Move selected images | |
| async function moveSelectedImages() { | |
| const selectedIds = Array.from(document.querySelectorAll('.image-checkbox:checked')) | |
| .map(cb => parseInt(cb.value)); | |
| if (selectedIds.length === 0) { | |
| alert('No images selected'); | |
| return; | |
| } | |
| const targetFolderId = document.getElementById('targetFolder').value || null; | |
| try { | |
| const response = await fetch('{{ url_for("qtab.qtab_move_images") }}', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| image_ids: selectedIds, | |
| target_folder_id: targetFolderId | |
| }) | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| window.location.reload(); | |
| } else { | |
| alert('Error: ' + (result.error || 'Failed to move images')); | |
| } | |
| } catch (error) { | |
| console.error('Error moving images:', error); | |
| alert('Error: ' + error.message); | |
| } | |
| } | |
| // Delete selected images | |
| async function deleteSelectedImages() { | |
| const selectedIds = Array.from(document.querySelectorAll('.image-checkbox:checked')) | |
| .map(cb => parseInt(cb.value)); | |
| if (selectedIds.length === 0) { | |
| alert('No images selected'); | |
| return; | |
| } | |
| if (!confirm(`Are you sure you want to delete ${selectedIds.length} image(s)?`)) { | |
| return; | |
| } | |
| try { | |
| // Delete each image | |
| for (const imageId of selectedIds) { | |
| await fetch(`{{ url_for('qtab.qtab_delete_image', image_id=0) }}`.replace('0', imageId), { | |
| method: 'DELETE' | |
| }); | |
| } | |
| window.location.reload(); | |
| } catch (error) { | |
| console.error('Error deleting images:', error); | |
| alert('Error: ' + error.message); | |
| } | |
| } | |
| async function deleteImage(imageId) { | |
| if (!confirm('Are you sure you want to delete this image?')) { | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`{{ url_for('qtab.qtab_delete_image', image_id=0) }}`.replace('0', imageId), { | |
| method: 'DELETE' | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| // Remove card from DOM | |
| const card = document.querySelector(`[data-image-id="${imageId}"]`); | |
| if (card) { | |
| card.remove(); | |
| } | |
| } else { | |
| alert('Error: ' + (result.error || 'Failed to delete image')); | |
| } | |
| } catch (error) { | |
| console.error('Error deleting image:', error); | |
| alert('Error: ' + error.message); | |
| } | |
| } | |
| </script> | |
| {% endblock %} | |