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>
301 lines
9.2 KiB
JavaScript
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;
|