Files
nextsnap/app/static/js/polish.js
kamaji cad4118f72 Add NextSnap PWA with photo gallery viewer and continuous capture
Offline-first photo capture app for Nextcloud with:
- Camera capture with continuous mode (auto-reopens after each photo)
- File browser with fullscreen image gallery, swipe navigation, and rename
- Upload queue with background sync engine
- Admin panel for Nextcloud user management
- Service worker for offline-first caching (v13)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 04:53:13 -06:00

301 lines
9.2 KiB
JavaScript

// NextSnap - Polish & UX Enhancements
'use strict';
const Polish = {
init() {
this.setupLoadingIndicator();
this.setupSmoothScroll();
this.setupFormValidation();
this.setupKeyboardShortcuts();
this.setupOfflineDetection();
this.setupImageLazyLoading();
console.log('Polish enhancements loaded');
},
// Global loading indicator
setupLoadingIndicator() {
this.loadingBar = document.createElement('div');
this.loadingBar.className = 'loading-bar';
this.loadingBar.style.display = 'none';
document.body.appendChild(this.loadingBar);
},
showLoading() {
if (this.loadingBar) {
this.loadingBar.style.display = 'block';
}
},
hideLoading() {
if (this.loadingBar) {
this.loadingBar.style.display = 'none';
}
},
// Smooth scroll for anchor links
setupSmoothScroll() {
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', (e) => {
const href = anchor.getAttribute('href');
if (href === '#') return;
e.preventDefault();
const target = document.querySelector(href);
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
},
// Enhanced form validation
setupFormValidation() {
document.querySelectorAll('form').forEach(form => {
form.addEventListener('submit', (e) => {
const invalidInputs = form.querySelectorAll(':invalid');
if (invalidInputs.length > 0) {
invalidInputs[0].focus();
this.shake(invalidInputs[0]);
}
});
});
// Real-time validation feedback
document.querySelectorAll('input[required], textarea[required]').forEach(input => {
input.addEventListener('blur', () => {
if (!input.validity.valid) {
this.shake(input);
}
});
});
},
// Shake animation for errors
shake(element) {
element.style.animation = 'inputShake 0.3s';
setTimeout(() => {
element.style.animation = '';
}, 300);
},
// Keyboard shortcuts
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Alt+1: Go to capture
if (e.altKey && e.key === '1') {
e.preventDefault();
window.location.href = '/capture';
}
// Alt+2: Go to queue
if (e.altKey && e.key === '2') {
e.preventDefault();
window.location.href = '/queue';
}
// Alt+3: Go to files
if (e.altKey && e.key === '3') {
e.preventDefault();
window.location.href = '/browser';
}
// Alt+R: Refresh/sync
if (e.altKey && e.key === 'r') {
e.preventDefault();
if (window.SyncEngine && window.SyncEngine.triggerSync) {
window.SyncEngine.triggerSync();
}
}
});
},
// Enhanced offline detection
setupOfflineDetection() {
let wasOffline = !navigator.onLine;
window.addEventListener('online', () => {
if (wasOffline) {
this.showToast('✓ Back online', 'success', 2000);
wasOffline = false;
}
});
window.addEventListener('offline', () => {
this.showToast('⚠️ You are offline', 'warning', 3000);
wasOffline = true;
});
},
// Lazy loading for images
setupImageLazyLoading() {
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
imageObserver.unobserve(img);
}
}
});
});
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
}
},
// Toast notification helper
showToast(message, type = 'info', duration = 3000) {
let toast = document.getElementById('global-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'global-toast';
toast.className = 'toast';
document.body.appendChild(toast);
}
toast.textContent = message;
toast.className = `toast ${type}`;
toast.style.display = 'block';
setTimeout(() => {
toast.style.display = 'none';
}, duration);
},
// Confirm dialog with better UX
async confirm(message, title = 'Confirm') {
return new Promise((resolve) => {
const modal = this.createConfirmModal(message, title);
document.body.appendChild(modal);
modal.querySelector('.confirm-yes').onclick = () => {
document.body.removeChild(modal);
resolve(true);
};
modal.querySelector('.confirm-no').onclick = () => {
document.body.removeChild(modal);
resolve(false);
};
modal.onclick = (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
resolve(false);
}
};
});
},
createConfirmModal(message, title) {
const modal = document.createElement('div');
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content">
<h3>${this.escapeHtml(title)}</h3>
<p>${this.escapeHtml(message)}</p>
<div class="modal-actions">
<button class="btn btn-secondary confirm-no">Cancel</button>
<button class="btn btn-primary confirm-yes">Confirm</button>
</div>
</div>
`;
return modal;
},
// Copy to clipboard with feedback
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
this.showToast('✓ Copied to clipboard', 'success', 1500);
return true;
} catch (err) {
this.showToast('❌ Failed to copy', 'error', 2000);
return false;
}
},
// Debounce helper
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
// Throttle helper
throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
},
// Escape HTML
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
// Format file size
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
},
// Format date relative
formatRelativeTime(date) {
const now = new Date();
const diffMs = now - date;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins} min ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
return date.toLocaleDateString();
},
// Vibrate if supported (for mobile feedback)
vibrate(pattern = 50) {
if ('vibrate' in navigator) {
navigator.vibrate(pattern);
}
}
};
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => Polish.init());
} else {
Polish.init();
}
// Make Polish globally available
window.Polish = Polish;