| |
| |
| |
| |
|
|
| |
|
|
| const toastContainer = document.createElement('div'); |
| toastContainer.className = 'toast-container'; |
| document.body.appendChild(toastContainer); |
|
|
| const toastIcons = { |
| success: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`, |
| error: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`, |
| info: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>` |
| }; |
|
|
| function showToast(title, message, type = 'info', duration = 4000) { |
| const toast = document.createElement('div'); |
| toast.className = `toast toast-${type}`; |
| |
| toast.innerHTML = ` |
| <div class="toast-icon">${toastIcons[type]}</div> |
| <div class="toast-content"> |
| <div class="toast-title">${title}</div> |
| ${message ? `<div class="toast-message">${message}</div>` : ''} |
| </div> |
| <button class="toast-close"> |
| <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> |
| </svg> |
| </button> |
| `; |
| |
| toast.querySelector('.toast-close').addEventListener('click', () => removeToast(toast)); |
| toastContainer.appendChild(toast); |
| |
| if (duration > 0) { |
| setTimeout(() => removeToast(toast), duration); |
| } |
| |
| return toast; |
| } |
|
|
| function removeToast(toast) { |
| if (!toast.parentNode) return; |
| toast.classList.add('hiding'); |
| setTimeout(() => toast.remove(), 300); |
| } |
|
|
| |
|
|
| function setButtonLoading(btn, loading, loadingText = 'Loading...') { |
| if (loading) { |
| btn.dataset.originalText = btn.innerHTML; |
| btn.innerHTML = `<span class="spinner"></span> ${loadingText}`; |
| btn.classList.add('loading'); |
| btn.disabled = true; |
| } else { |
| btn.innerHTML = btn.dataset.originalText || btn.innerHTML; |
| btn.classList.remove('loading'); |
| btn.disabled = false; |
| } |
| } |
|
|
| function showProgress(container, show = true, indeterminate = true) { |
| if (!container) return; |
| |
| if (show) { |
| container.classList.add('active'); |
| const fill = container.querySelector('.progress-fill'); |
| if (fill) { |
| if (indeterminate) { |
| fill.classList.add('indeterminate'); |
| fill.style.width = ''; |
| } else { |
| fill.classList.remove('indeterminate'); |
| } |
| } |
| } else { |
| container.classList.remove('active'); |
| } |
| } |
|
|
| |
|
|
| function validateUrl(input, errorMsg = 'Invalid URL') { |
| const value = input.value.trim(); |
| const formGroup = input.closest('.form-group'); |
| |
| if (!value) { |
| clearFieldError(formGroup); |
| return true; |
| } |
| |
| try { |
| new URL(value); |
| clearFieldError(formGroup); |
| return true; |
| } catch { |
| setFieldError(formGroup, errorMsg); |
| return false; |
| } |
| } |
|
|
| function setFieldError(formGroup, message) { |
| if (!formGroup) return; |
| formGroup.classList.add('error'); |
| |
| let errorEl = formGroup.querySelector('.form-error'); |
| if (!errorEl) { |
| errorEl = document.createElement('div'); |
| errorEl.className = 'form-error'; |
| errorEl.innerHTML = ` |
| <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/> |
| </svg> |
| <span></span> |
| `; |
| formGroup.appendChild(errorEl); |
| } |
| |
| errorEl.querySelector('span').textContent = message; |
| } |
|
|
| function clearFieldError(formGroup) { |
| if (!formGroup) return; |
| formGroup.classList.remove('error'); |
| } |
|
|
| |
|
|
| function initDropZone(dropZone, fileInput, options = {}) { |
| const { |
| onFile = () => {}, |
| maxSize = 50 * 1024 * 1024 |
| } = options; |
| |
| const fileDisplay = dropZone.querySelector('.drop-zone-file'); |
| const fileName = dropZone.querySelector('.drop-zone-file-name'); |
| const fileSize = dropZone.querySelector('.drop-zone-file-size'); |
| const removeBtn = dropZone.querySelector('.drop-zone-file-remove'); |
| |
| dropZone.addEventListener('click', (e) => { |
| if (e.target === removeBtn) return; |
| fileInput.click(); |
| }); |
| |
| ['dragenter', 'dragover'].forEach(event => { |
| dropZone.addEventListener(event, (e) => { |
| e.preventDefault(); |
| dropZone.classList.add('drag-over'); |
| }); |
| }); |
| |
| ['dragleave', 'drop'].forEach(event => { |
| dropZone.addEventListener(event, (e) => { |
| e.preventDefault(); |
| dropZone.classList.remove('drag-over'); |
| }); |
| }); |
| |
| dropZone.addEventListener('drop', (e) => { |
| const files = e.dataTransfer.files; |
| if (files.length > 0) handleFile(files[0]); |
| }); |
| |
| fileInput.addEventListener('change', () => { |
| if (fileInput.files.length > 0) handleFile(fileInput.files[0]); |
| }); |
| |
| if (removeBtn) { |
| removeBtn.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| clearFile(); |
| }); |
| } |
| |
| function handleFile(file) { |
| if (file.size > maxSize) { |
| showToast('File Too Large', `Max size: ${formatFileSize(maxSize)}`, 'error'); |
| return; |
| } |
| |
| if (fileDisplay) { |
| fileDisplay.classList.add('active'); |
| if (fileName) fileName.textContent = file.name; |
| if (fileSize) fileSize.textContent = formatFileSize(file.size); |
| } |
| |
| const dt = new DataTransfer(); |
| dt.items.add(file); |
| fileInput.files = dt.files; |
| |
| onFile(file); |
| } |
| |
| function clearFile() { |
| fileInput.value = ''; |
| if (fileDisplay) fileDisplay.classList.remove('active'); |
| onFile(null); |
| } |
| |
| return { handleFile, clearFile }; |
| } |
|
|
| function formatFileSize(bytes) { |
| if (bytes < 1024) return bytes + ' B'; |
| if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; |
| return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; |
| } |
|
|
| |
|
|
| let currentModal = null; |
|
|
| function showImageModal(imageUrl, filename, dimensions, size) { |
| closeImageModal(); |
| |
| const modal = document.createElement('div'); |
| modal.className = 'modal-overlay'; |
| modal.innerHTML = ` |
| <div class="modal-content"> |
| <button class="modal-close"> |
| <svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> |
| </svg> |
| </button> |
| <img class="modal-image" src="${imageUrl}" alt="${filename}" /> |
| <div class="modal-footer"> |
| <div class="modal-info"> |
| <strong>${filename}</strong><br> |
| ${dimensions || 'Unknown'} • ${size || ''} |
| </div> |
| <a href="${imageUrl}" target="_blank" class="btn btn-secondary">Open Original</a> |
| </div> |
| </div> |
| `; |
| |
| document.body.appendChild(modal); |
| requestAnimationFrame(() => modal.classList.add('active')); |
| |
| modal.querySelector('.modal-close').addEventListener('click', closeImageModal); |
| modal.addEventListener('click', (e) => { |
| if (e.target === modal) closeImageModal(); |
| }); |
| |
| document.addEventListener('keydown', handleModalEsc); |
| currentModal = modal; |
| } |
|
|
| function closeImageModal() { |
| if (currentModal) { |
| currentModal.classList.remove('active'); |
| setTimeout(() => currentModal?.remove(), 200); |
| currentModal = null; |
| document.removeEventListener('keydown', handleModalEsc); |
| } |
| } |
|
|
| function handleModalEsc(e) { |
| if (e.key === 'Escape') closeImageModal(); |
| } |
|
|
| |
|
|
| const shortcuts = {}; |
|
|
| function registerShortcut(key, callback, description = '') { |
| shortcuts[key.toLowerCase()] = { callback, description }; |
| } |
|
|
| document.addEventListener('keydown', (e) => { |
| if (e.target.matches('input, textarea, select')) return; |
| |
| let key = ''; |
| if (e.ctrlKey || e.metaKey) key += 'ctrl+'; |
| if (e.shiftKey) key += 'shift+'; |
| if (e.altKey) key += 'alt+'; |
| key += e.key.toLowerCase(); |
| |
| const shortcut = shortcuts[key]; |
| if (shortcut) { |
| e.preventDefault(); |
| shortcut.callback(); |
| } |
| }); |
|
|
|
|
| |
|
|
| function showSuccessAnimation(title = 'Success!', message = 'Operation completed') { |
| |
| const overlay = document.createElement('div'); |
| overlay.className = 'success-overlay'; |
| overlay.innerHTML = ` |
| <div class="success-content"> |
| <div class="success-icon"> |
| <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/> |
| </svg> |
| </div> |
| <div class="success-title">${title}</div> |
| <div class="success-message">${message}</div> |
| <button class="btn btn-primary">Continue</button> |
| </div> |
| `; |
| |
| document.body.appendChild(overlay); |
| |
| |
| createConfetti(overlay); |
| |
| |
| requestAnimationFrame(() => overlay.classList.add('active')); |
| |
| |
| const closeBtn = overlay.querySelector('.btn'); |
| closeBtn.addEventListener('click', () => closeSuccessAnimation(overlay)); |
| |
| overlay.addEventListener('click', (e) => { |
| if (e.target === overlay) closeSuccessAnimation(overlay); |
| }); |
| |
| |
| setTimeout(() => closeSuccessAnimation(overlay), 3000); |
| } |
|
|
| function closeSuccessAnimation(overlay) { |
| if (!overlay) return; |
| overlay.style.opacity = '0'; |
| setTimeout(() => overlay.remove(), 300); |
| } |
|
|
| function createConfetti(container) { |
| const colors = ['#4caf50', '#66bb6a', '#81c784', '#a5d6a7', '#c8e6c9', '#2e7d32']; |
| |
| for (let i = 0; i < 50; i++) { |
| const confetti = document.createElement('div'); |
| confetti.className = 'confetti'; |
| confetti.style.left = Math.random() * 100 + '%'; |
| confetti.style.top = '-10px'; |
| confetti.style.background = colors[Math.floor(Math.random() * colors.length)]; |
| confetti.style.animationDelay = Math.random() * 0.5 + 's'; |
| confetti.style.animationDuration = (2 + Math.random() * 2) + 's'; |
| container.appendChild(confetti); |
| } |
| } |
|
|
| |
|
|
| class StepIndicator { |
| constructor(containerId, steps) { |
| this.container = document.getElementById(containerId); |
| this.steps = steps; |
| this.currentStep = 0; |
| this.render(); |
| } |
| |
| render() { |
| if (!this.container) return; |
| |
| let html = ''; |
| this.steps.forEach((step, index) => { |
| const isActive = index === this.currentStep; |
| const isCompleted = index < this.currentStep; |
| |
| html += ` |
| <div class="step ${isActive ? 'active' : ''} ${isCompleted ? 'completed' : ''}"> |
| <div class="step-number ${isActive ? 'pulse' : ''}"> |
| ${isCompleted ? '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>' : index + 1} |
| </div> |
| <span class="step-label">${step}</span> |
| </div> |
| `; |
| |
| if (index < this.steps.length - 1) { |
| html += `<div class="step-connector ${isCompleted ? 'completed' : ''}"></div>`; |
| } |
| }); |
| |
| this.container.innerHTML = html; |
| } |
| |
| setStep(stepIndex) { |
| this.currentStep = Math.max(0, Math.min(stepIndex, this.steps.length - 1)); |
| this.render(); |
| } |
| |
| next() { |
| if (this.currentStep < this.steps.length - 1) { |
| this.currentStep++; |
| this.render(); |
| } |
| } |
| |
| prev() { |
| if (this.currentStep > 0) { |
| this.currentStep--; |
| this.render(); |
| } |
| } |
| |
| complete() { |
| this.currentStep = this.steps.length; |
| this.render(); |
| } |
| |
| reset() { |
| this.currentStep = 0; |
| this.render(); |
| } |
| } |
|
|
| |
|
|
| function showErrorState(container, title, message, actions = []) { |
| const errorHtml = ` |
| <div class="error-state"> |
| <div class="error-state-icon"> |
| <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/> |
| </svg> |
| </div> |
| <div class="error-state-title">${title}</div> |
| <div class="error-state-message">${message}</div> |
| <div class="error-state-actions"> |
| ${actions.map(a => `<button class="btn ${a.primary ? 'btn-primary' : 'btn-secondary'}" data-action="${a.id}">${a.label}</button>`).join('')} |
| </div> |
| </div> |
| `; |
| |
| container.innerHTML = errorHtml; |
| |
| |
| actions.forEach(action => { |
| const btn = container.querySelector(`[data-action="${action.id}"]`); |
| if (btn && action.handler) { |
| btn.addEventListener('click', action.handler); |
| } |
| }); |
| } |
|
|
| function showInlineError(container, title, message, actionLabel, actionHandler) { |
| const errorEl = document.createElement('div'); |
| errorEl.className = 'inline-error'; |
| errorEl.innerHTML = ` |
| <div class="inline-error-icon"> |
| <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/> |
| </svg> |
| </div> |
| <div class="inline-error-content"> |
| <div class="inline-error-title">${title}</div> |
| <div class="inline-error-message">${message}</div> |
| </div> |
| ${actionLabel ? `<button class="inline-error-action">${actionLabel}</button>` : ''} |
| `; |
| |
| if (actionLabel && actionHandler) { |
| errorEl.querySelector('.inline-error-action').addEventListener('click', () => { |
| errorEl.remove(); |
| actionHandler(); |
| }); |
| } |
| |
| container.appendChild(errorEl); |
| return errorEl; |
| } |
|
|
| |
|
|
| function showEmptyState(container, icon, title, message, actions = []) { |
| const emptyHtml = ` |
| <div class="empty-state"> |
| <div class="empty-state-illustration"> |
| ${icon} |
| </div> |
| <div class="empty-state-title">${title}</div> |
| <div class="empty-state-message">${message}</div> |
| ${actions.length > 0 ? ` |
| <div class="empty-state-actions"> |
| ${actions.map(a => `<button class="btn ${a.primary ? 'btn-primary' : 'btn-secondary'}" data-action="${a.id}">${a.label}</button>`).join('')} |
| </div> |
| ` : ''} |
| </div> |
| `; |
| |
| container.innerHTML = emptyHtml; |
| |
| |
| actions.forEach(action => { |
| const btn = container.querySelector(`[data-action="${action.id}"]`); |
| if (btn && action.handler) { |
| btn.addEventListener('click', action.handler); |
| } |
| }); |
| } |
|
|
| function showPreviewEmpty(container) { |
| container.innerHTML = ` |
| <div class="preview-empty"> |
| <div class="preview-empty-icon"> |
| <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/> |
| </svg> |
| </div> |
| <div class="preview-empty-title">No PDF loaded</div> |
| <div class="preview-empty-message">Upload a file or enter a URL to preview</div> |
| </div> |
| `; |
| } |
|
|
| function showImageGridEmpty(container) { |
| container.innerHTML = ` |
| <div class="image-grid-empty"> |
| <div class="image-grid-empty-icon"> |
| <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/> |
| </svg> |
| </div> |
| <div class="image-grid-empty-title">No images yet</div> |
| <div class="image-grid-empty-message">Enter a webpage URL above and click "Fetch Images" to get started</div> |
| </div> |
| `; |
| } |
|
|
| |
|
|
| function showFeatureHint(container, icon, title, message, dismissKey) { |
| |
| if (localStorage.getItem(`hint_${dismissKey}`)) return; |
| |
| const hint = document.createElement('div'); |
| hint.className = 'feature-hint'; |
| hint.innerHTML = ` |
| <div class="feature-hint-icon">${icon}</div> |
| <div class="feature-hint-content"> |
| <div class="feature-hint-title">${title}</div> |
| <div class="feature-hint-message">${message}</div> |
| </div> |
| <button class="feature-hint-dismiss"> |
| <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> |
| </svg> |
| </button> |
| `; |
| |
| hint.querySelector('.feature-hint-dismiss').addEventListener('click', () => { |
| localStorage.setItem(`hint_${dismissKey}`, 'true'); |
| hint.style.opacity = '0'; |
| hint.style.transform = 'translateY(-10px)'; |
| setTimeout(() => hint.remove(), 300); |
| }); |
| |
| container.prepend(hint); |
| } |
|
|
| |
|
|
| function initTooltips() { |
| |
| document.querySelectorAll('[data-tooltip]').forEach(el => { |
| if (!el.classList.contains('tooltip')) { |
| el.classList.add('tooltip'); |
| } |
| }); |
| } |
|
|
| |
| document.addEventListener('DOMContentLoaded', initTooltips); |
|
|