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>
This commit is contained in:
300
app/static/js/polish.js
Normal file
300
app/static/js/polish.js
Normal file
@@ -0,0 +1,300 @@
|
||||
// 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;
|
||||
Reference in New Issue
Block a user