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:
246
app/static/js/admin.js
Normal file
246
app/static/js/admin.js
Normal file
@@ -0,0 +1,246 @@
|
||||
// NextSnap - Admin Panel Logic
|
||||
'use strict';
|
||||
|
||||
const Admin = {
|
||||
users: [],
|
||||
|
||||
async init() {
|
||||
await this.loadUsers();
|
||||
this.setupEventListeners();
|
||||
},
|
||||
|
||||
setupEventListeners() {
|
||||
document.getElementById('add-user-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.createUser();
|
||||
});
|
||||
|
||||
document.getElementById('refresh-btn').addEventListener('click', () => {
|
||||
this.loadUsers();
|
||||
});
|
||||
},
|
||||
|
||||
async loadUsers() {
|
||||
const userList = document.getElementById('user-list');
|
||||
const loadingMsg = document.getElementById('loading-msg');
|
||||
const errorMsg = document.getElementById('error-msg');
|
||||
|
||||
loadingMsg.style.display = 'block';
|
||||
errorMsg.style.display = 'none';
|
||||
userList.innerHTML = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/users');
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to load users');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.users = data.users || [];
|
||||
|
||||
loadingMsg.style.display = 'none';
|
||||
|
||||
if (this.users.length === 0) {
|
||||
userList.innerHTML = '<tr><td colspan="5" class="empty-state">No users found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
this.users.forEach(user => {
|
||||
const row = this.createUserRow(user);
|
||||
userList.appendChild(row);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error);
|
||||
loadingMsg.style.display = 'none';
|
||||
errorMsg.textContent = error.message;
|
||||
errorMsg.style.display = 'block';
|
||||
}
|
||||
},
|
||||
|
||||
createUserRow(user) {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${this.escapeHtml(user.id)}</td>
|
||||
<td>${this.escapeHtml(user.displayname || '-')}</td>
|
||||
<td>${this.escapeHtml(user.email || '-')}</td>
|
||||
<td>
|
||||
<span class="badge ${user.enabled ? 'badge-success' : 'badge-danger'}">
|
||||
${user.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
${user.enabled ?
|
||||
`<button class="btn-action btn-warning" onclick="Admin.disableUser('${user.id}')">Disable</button>` :
|
||||
`<button class="btn-action btn-success" onclick="Admin.enableUser('${user.id}')">Enable</button>`
|
||||
}
|
||||
<button class="btn-action btn-danger" onclick="Admin.confirmDeleteUser('${user.id}')">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
return row;
|
||||
},
|
||||
|
||||
async createUser() {
|
||||
const form = document.getElementById('add-user-form');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const formError = document.getElementById('form-error');
|
||||
const formSuccess = document.getElementById('form-success');
|
||||
|
||||
const username = document.getElementById('new-username').value.trim();
|
||||
const password = document.getElementById('new-password').value;
|
||||
const email = document.getElementById('new-email').value.trim();
|
||||
const displayName = document.getElementById('new-displayname').value.trim();
|
||||
|
||||
formError.style.display = 'none';
|
||||
formSuccess.style.display = 'none';
|
||||
|
||||
if (!username || !password) {
|
||||
formError.textContent = 'Username and password are required';
|
||||
formError.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Creating...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
password: password,
|
||||
email: email || null,
|
||||
displayName: displayName || null
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Failed to create user');
|
||||
}
|
||||
|
||||
formSuccess.textContent = `User "${username}" created successfully!`;
|
||||
formSuccess.style.display = 'block';
|
||||
|
||||
form.reset();
|
||||
|
||||
setTimeout(() => {
|
||||
this.loadUsers();
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
formError.textContent = error.message;
|
||||
formError.style.display = 'block';
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Create User';
|
||||
}
|
||||
},
|
||||
|
||||
async enableUser(username) {
|
||||
if (!confirm(`Enable user "${username}"?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${username}/enable`, {
|
||||
method: 'PUT'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to enable user');
|
||||
}
|
||||
|
||||
this.showToast(`User "${username}" enabled`, 'success');
|
||||
this.loadUsers();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error enabling user:', error);
|
||||
this.showToast(error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async disableUser(username) {
|
||||
if (!confirm(`Disable user "${username}"?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${username}/disable`, {
|
||||
method: 'PUT'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to disable user');
|
||||
}
|
||||
|
||||
this.showToast(`User "${username}" disabled`, 'success');
|
||||
this.loadUsers();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error disabling user:', error);
|
||||
this.showToast(error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
confirmDeleteUser(username) {
|
||||
const modal = document.getElementById('delete-modal');
|
||||
const confirmBtn = document.getElementById('confirm-delete');
|
||||
|
||||
document.getElementById('delete-username').textContent = username;
|
||||
modal.style.display = 'flex';
|
||||
|
||||
confirmBtn.onclick = () => {
|
||||
this.deleteUser(username);
|
||||
this.hideDeleteModal();
|
||||
};
|
||||
},
|
||||
|
||||
hideDeleteModal() {
|
||||
document.getElementById('delete-modal').style.display = 'none';
|
||||
},
|
||||
|
||||
async deleteUser(username) {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${username}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to delete user');
|
||||
}
|
||||
|
||||
this.showToast(`User "${username}" deleted`, 'success');
|
||||
this.loadUsers();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
this.showToast(error.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
showToast(message, type) {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = message;
|
||||
toast.className = `toast ${type}`;
|
||||
toast.style.display = 'block';
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.display = 'none';
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
};
|
||||
|
||||
window.Admin = Admin;
|
||||
111
app/static/js/app.js
Normal file
111
app/static/js/app.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// NextSnap - Main application logic
|
||||
'use strict';
|
||||
|
||||
const NextSnap = {
|
||||
version: '1.0.0',
|
||||
isOnline: navigator.onLine,
|
||||
|
||||
init() {
|
||||
console.log(`NextSnap v${this.version} initializing...`);
|
||||
this.setupConnectivityMonitoring();
|
||||
this.setupServiceWorkerMessaging();
|
||||
this.checkHealth();
|
||||
},
|
||||
|
||||
setupConnectivityMonitoring() {
|
||||
// Update online status on events
|
||||
window.addEventListener('online', () => {
|
||||
console.log('Network: Online');
|
||||
this.isOnline = true;
|
||||
this.updateConnectivityIndicator();
|
||||
// Sync is handled by SyncEngine's own online listener in sync.js
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
console.log('Network: Offline');
|
||||
this.isOnline = false;
|
||||
this.updateConnectivityIndicator();
|
||||
});
|
||||
|
||||
// Initial status
|
||||
this.updateConnectivityIndicator();
|
||||
},
|
||||
|
||||
updateConnectivityIndicator() {
|
||||
const indicator = document.querySelector('.connectivity-indicator');
|
||||
if (!indicator) return;
|
||||
|
||||
indicator.classList.remove('online', 'offline', 'syncing');
|
||||
|
||||
if (this.isOnline) {
|
||||
indicator.classList.add('online');
|
||||
indicator.title = 'Online';
|
||||
} else {
|
||||
indicator.classList.add('offline');
|
||||
indicator.title = 'Offline';
|
||||
}
|
||||
},
|
||||
|
||||
setSyncingStatus(isSyncing) {
|
||||
const indicator = document.querySelector('.connectivity-indicator');
|
||||
if (!indicator) return;
|
||||
|
||||
if (isSyncing) {
|
||||
indicator.classList.add('syncing');
|
||||
indicator.title = 'Syncing...';
|
||||
} else {
|
||||
indicator.classList.remove('syncing');
|
||||
indicator.title = this.isOnline ? 'Online' : 'Offline';
|
||||
}
|
||||
},
|
||||
|
||||
setupServiceWorkerMessaging() {
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
// Listen for messages from service worker
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
console.log('Message from SW:', event.data);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Force service worker update
|
||||
async updateServiceWorker() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
if (registration) {
|
||||
await registration.update();
|
||||
console.log('Service worker update check triggered');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Clear all caches (for debugging)
|
||||
async clearCache() {
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
navigator.serviceWorker.controller.postMessage({
|
||||
type: 'CLEAR_CACHE'
|
||||
});
|
||||
console.log('Cache clear requested');
|
||||
}
|
||||
},
|
||||
|
||||
async checkHealth() {
|
||||
try {
|
||||
const response = await fetch('/api/health');
|
||||
const data = await response.json();
|
||||
console.log('Health check:', data);
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => NextSnap.init());
|
||||
} else {
|
||||
NextSnap.init();
|
||||
}
|
||||
|
||||
// Make NextSnap globally available
|
||||
window.NextSnap = NextSnap;
|
||||
160
app/static/js/auth.js
Normal file
160
app/static/js/auth.js
Normal file
@@ -0,0 +1,160 @@
|
||||
// NextSnap - Authentication logic
|
||||
'use strict';
|
||||
|
||||
const Auth = {
|
||||
init() {
|
||||
const loginForm = document.getElementById('login-form');
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', this.handleLogin.bind(this));
|
||||
|
||||
// Add input validation
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passwordInput = document.getElementById('password');
|
||||
|
||||
if (usernameInput) {
|
||||
usernameInput.addEventListener('blur', () => this.validateUsername());
|
||||
usernameInput.addEventListener('input', () => this.clearFieldError('username'));
|
||||
}
|
||||
|
||||
if (passwordInput) {
|
||||
passwordInput.addEventListener('input', () => this.clearFieldError('password'));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
validateUsername() {
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const usernameError = document.getElementById('username-error');
|
||||
const usernameInput = document.getElementById('username');
|
||||
|
||||
if (!username) {
|
||||
usernameError.textContent = 'Username is required';
|
||||
usernameInput.classList.add('error');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (username.length < 2) {
|
||||
usernameError.textContent = 'Username must be at least 2 characters';
|
||||
usernameInput.classList.add('error');
|
||||
return false;
|
||||
}
|
||||
|
||||
usernameError.textContent = '';
|
||||
usernameInput.classList.remove('error');
|
||||
return true;
|
||||
},
|
||||
|
||||
validatePassword() {
|
||||
const password = document.getElementById('password').value;
|
||||
const passwordError = document.getElementById('password-error');
|
||||
const passwordInput = document.getElementById('password');
|
||||
|
||||
if (!password) {
|
||||
passwordError.textContent = 'Password is required';
|
||||
passwordInput.classList.add('error');
|
||||
return false;
|
||||
}
|
||||
|
||||
passwordError.textContent = '';
|
||||
passwordInput.classList.remove('error');
|
||||
return true;
|
||||
},
|
||||
|
||||
clearFieldError(field) {
|
||||
const errorElement = document.getElementById(`${field}-error`);
|
||||
const inputElement = document.getElementById(field);
|
||||
if (errorElement) errorElement.textContent = '';
|
||||
if (inputElement) inputElement.classList.remove('error');
|
||||
},
|
||||
|
||||
async handleLogin(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// Validate fields
|
||||
const usernameValid = this.validateUsername();
|
||||
const passwordValid = this.validatePassword();
|
||||
|
||||
if (!usernameValid || !passwordValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
const loginBtnText = document.getElementById('login-btn-text');
|
||||
const loginBtnLoading = document.getElementById('login-btn-loading');
|
||||
|
||||
// Clear previous error
|
||||
errorMessage.classList.add('hidden');
|
||||
errorMessage.textContent = '';
|
||||
|
||||
// Disable button and show loading state
|
||||
loginBtn.disabled = true;
|
||||
loginBtnText.classList.add('hidden');
|
||||
loginBtnLoading.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// Login successful - redirect to capture page
|
||||
window.location.href = '/capture';
|
||||
} else {
|
||||
// Login failed - show error
|
||||
const errorText = data.error || 'Login failed. Please check your credentials and try again.';
|
||||
errorMessage.textContent = errorText;
|
||||
errorMessage.classList.remove('hidden');
|
||||
|
||||
// Focus back on username for retry
|
||||
document.getElementById('username').focus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
errorMessage.textContent = 'Network error. Please check your connection and try again.';
|
||||
errorMessage.classList.remove('hidden');
|
||||
} finally {
|
||||
// Re-enable button and restore normal state
|
||||
loginBtn.disabled = false;
|
||||
loginBtnText.classList.remove('hidden');
|
||||
loginBtnLoading.classList.add('hidden');
|
||||
}
|
||||
},
|
||||
|
||||
async checkStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/status');
|
||||
const data = await response.json();
|
||||
return data.authenticated ? data : null;
|
||||
} catch (error) {
|
||||
console.error('Auth status check failed:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/';
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
// Redirect anyway
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => Auth.init());
|
||||
} else {
|
||||
Auth.init();
|
||||
}
|
||||
318
app/static/js/camera.js
Normal file
318
app/static/js/camera.js
Normal file
@@ -0,0 +1,318 @@
|
||||
// NextSnap - Camera Capture and JPEG Conversion
|
||||
'use strict';
|
||||
|
||||
const Camera = {
|
||||
input: null,
|
||||
currentUsername: null,
|
||||
|
||||
init(username) {
|
||||
this.currentUsername = username;
|
||||
this.input = document.getElementById('camera-input');
|
||||
const captureBtn = document.getElementById('capture-btn');
|
||||
|
||||
if (this.input && captureBtn) {
|
||||
captureBtn.addEventListener('click', () => this.triggerCapture());
|
||||
this.input.addEventListener('change', (e) => this.handleCapture(e));
|
||||
}
|
||||
},
|
||||
|
||||
triggerCapture() {
|
||||
if (this.input) {
|
||||
this.input.click();
|
||||
}
|
||||
},
|
||||
|
||||
async handleCapture(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
// Show loading state
|
||||
this.showCaptureLoading();
|
||||
|
||||
// Convert to JPEG
|
||||
const jpegBlob = await this.convertToJPEG(file);
|
||||
|
||||
// Generate filename
|
||||
const filename = this.generateFilename();
|
||||
|
||||
// Get target path
|
||||
const targetPath = localStorage.getItem('nextsnap_upload_path') || '/';
|
||||
|
||||
// Save to IndexedDB
|
||||
const photoId = await Storage.savePhoto({
|
||||
username: this.currentUsername,
|
||||
timestamp: Date.now(),
|
||||
filename: filename,
|
||||
targetPath: targetPath,
|
||||
blob: jpegBlob,
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
// Show success feedback
|
||||
this.showCaptureSuccess(jpegBlob);
|
||||
|
||||
// Update recent photos display
|
||||
this.updateRecentPhotos();
|
||||
|
||||
// Update pending count
|
||||
this.updatePendingCount();
|
||||
|
||||
// Clear input for next capture
|
||||
event.target.value = '';
|
||||
|
||||
// Trigger sync if online (will be implemented in Phase 7)
|
||||
if (navigator.onLine && typeof Sync !== 'undefined') {
|
||||
Sync.triggerSync();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error capturing photo:', error);
|
||||
this.showCaptureError(error.message);
|
||||
}
|
||||
},
|
||||
|
||||
async convertToJPEG(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = async () => {
|
||||
try {
|
||||
// Get EXIF orientation
|
||||
const orientation = await this.getOrientation(file);
|
||||
|
||||
// Create canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Set canvas dimensions based on orientation
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
if (orientation >= 5 && orientation <= 8) {
|
||||
// Swap dimensions for rotated images
|
||||
canvas.width = height;
|
||||
canvas.height = width;
|
||||
} else {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
|
||||
// Apply orientation transformation
|
||||
this.applyOrientation(ctx, orientation, width, height);
|
||||
|
||||
// Draw image
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Convert to JPEG blob
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Failed to convert image to JPEG'));
|
||||
}
|
||||
},
|
||||
'image/jpeg',
|
||||
0.92
|
||||
);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
img.src = e.target.result;
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error('Failed to read file'));
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
},
|
||||
|
||||
async getOrientation(file) {
|
||||
// Simple EXIF orientation detection
|
||||
// For production, consider using a library like exif-js
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
const view = new DataView(e.target.result);
|
||||
|
||||
if (view.getUint16(0, false) !== 0xFFD8) {
|
||||
resolve(1); // Not a JPEG
|
||||
return;
|
||||
}
|
||||
|
||||
const length = view.byteLength;
|
||||
let offset = 2;
|
||||
|
||||
while (offset < length) {
|
||||
if (view.getUint16(offset + 2, false) <= 8) {
|
||||
resolve(1);
|
||||
return;
|
||||
}
|
||||
const marker = view.getUint16(offset, false);
|
||||
offset += 2;
|
||||
|
||||
if (marker === 0xFFE1) {
|
||||
// EXIF marker
|
||||
const little = view.getUint16(offset + 8, false) === 0x4949;
|
||||
offset += 10;
|
||||
|
||||
const tags = view.getUint16(offset, little);
|
||||
offset += 2;
|
||||
|
||||
for (let i = 0; i < tags; i++) {
|
||||
if (view.getUint16(offset + (i * 12), little) === 0x0112) {
|
||||
resolve(view.getUint16(offset + (i * 12) + 8, little));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if ((marker & 0xFF00) !== 0xFF00) {
|
||||
break;
|
||||
} else {
|
||||
offset += view.getUint16(offset, false);
|
||||
}
|
||||
}
|
||||
|
||||
resolve(1); // Default orientation
|
||||
};
|
||||
|
||||
reader.onerror = () => resolve(1);
|
||||
reader.readAsArrayBuffer(file.slice(0, 64 * 1024));
|
||||
});
|
||||
},
|
||||
|
||||
applyOrientation(ctx, orientation, width, height) {
|
||||
switch (orientation) {
|
||||
case 2:
|
||||
// Horizontal flip
|
||||
ctx.transform(-1, 0, 0, 1, width, 0);
|
||||
break;
|
||||
case 3:
|
||||
// 180° rotate
|
||||
ctx.transform(-1, 0, 0, -1, width, height);
|
||||
break;
|
||||
case 4:
|
||||
// Vertical flip
|
||||
ctx.transform(1, 0, 0, -1, 0, height);
|
||||
break;
|
||||
case 5:
|
||||
// Vertical flip + 90° rotate
|
||||
ctx.transform(0, 1, 1, 0, 0, 0);
|
||||
break;
|
||||
case 6:
|
||||
// 90° rotate
|
||||
ctx.transform(0, 1, -1, 0, height, 0);
|
||||
break;
|
||||
case 7:
|
||||
// Horizontal flip + 90° rotate
|
||||
ctx.transform(0, -1, -1, 0, height, width);
|
||||
break;
|
||||
case 8:
|
||||
// 270° rotate
|
||||
ctx.transform(0, -1, 1, 0, 0, width);
|
||||
break;
|
||||
default:
|
||||
// No transformation
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
generateFilename() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${this.currentUsername}_${year}${month}${day}_${hours}${minutes}${seconds}.jpg`;
|
||||
},
|
||||
|
||||
showCaptureLoading() {
|
||||
const btn = document.getElementById('capture-btn');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Processing...';
|
||||
}
|
||||
},
|
||||
|
||||
showCaptureSuccess(blob) {
|
||||
const btn = document.getElementById('capture-btn');
|
||||
if (btn) {
|
||||
btn.textContent = '✓ Photo Saved!';
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📷 Take Photo';
|
||||
}, 1500);
|
||||
}
|
||||
},
|
||||
|
||||
showCaptureError(message) {
|
||||
const btn = document.getElementById('capture-btn');
|
||||
if (btn) {
|
||||
btn.textContent = '❌ Error';
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '📷 Take Photo';
|
||||
}, 2000);
|
||||
}
|
||||
alert('Failed to capture photo: ' + message);
|
||||
},
|
||||
|
||||
async updateRecentPhotos() {
|
||||
if (!this.currentUsername) return;
|
||||
|
||||
const thumbnailsContainer = document.getElementById('photo-thumbnails');
|
||||
if (!thumbnailsContainer) return;
|
||||
|
||||
const recentPhotos = await Storage.getRecentPhotos(this.currentUsername, 5);
|
||||
|
||||
if (recentPhotos.length === 0) {
|
||||
thumbnailsContainer.innerHTML = '<p class="empty-state">No photos captured yet</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const photo of recentPhotos) {
|
||||
const url = URL.createObjectURL(photo.blob);
|
||||
const statusClass = photo.status === 'verified' ? 'verified' :
|
||||
photo.status === 'pending' ? 'pending' :
|
||||
photo.status === 'uploading' ? 'uploading' : '';
|
||||
|
||||
html += `
|
||||
<div class="thumbnail">
|
||||
<img src="${url}" alt="${photo.filename}">
|
||||
<span class="status-badge ${statusClass}"></span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
thumbnailsContainer.innerHTML = html;
|
||||
},
|
||||
|
||||
async updatePendingCount() {
|
||||
if (!this.currentUsername) return;
|
||||
|
||||
const countElement = document.getElementById('pending-count');
|
||||
if (!countElement) return;
|
||||
|
||||
const count = await Storage.getPhotoCount(this.currentUsername, 'pending');
|
||||
countElement.textContent = `${count} pending`;
|
||||
}
|
||||
};
|
||||
530
app/static/js/filebrowser.js
Normal file
530
app/static/js/filebrowser.js
Normal file
@@ -0,0 +1,530 @@
|
||||
// NextSnap - File Browser Logic with Photo Gallery
|
||||
'use strict';
|
||||
|
||||
const FileBrowser = {
|
||||
currentPath: '/',
|
||||
|
||||
// Gallery state
|
||||
galleryOpen: false,
|
||||
galleryImages: [],
|
||||
galleryIndex: 0,
|
||||
galleryTouchStartX: 0,
|
||||
galleryTouchStartY: 0,
|
||||
galleryTouchEndX: 0,
|
||||
galleryTouchEndY: 0,
|
||||
galleryPreloaded: {},
|
||||
|
||||
init() {
|
||||
this.loadCurrentPath();
|
||||
this.loadDirectory(this.currentPath);
|
||||
this.setupEventListeners();
|
||||
},
|
||||
|
||||
setupEventListeners() {
|
||||
const newFolderBtn = document.getElementById('new-folder-btn');
|
||||
if (newFolderBtn) {
|
||||
newFolderBtn.addEventListener('click', () => this.createNewFolder());
|
||||
}
|
||||
|
||||
// Gallery controls
|
||||
document.getElementById('gallery-close-btn').addEventListener('click', () => this.closeGallery());
|
||||
document.getElementById('gallery-prev-btn').addEventListener('click', () => this.galleryPrev());
|
||||
document.getElementById('gallery-next-btn').addEventListener('click', () => this.galleryNext());
|
||||
document.getElementById('gallery-rename-btn').addEventListener('click', () => this.openRenameModal());
|
||||
|
||||
// Rename modal
|
||||
document.getElementById('gallery-rename-cancel').addEventListener('click', () => this.closeRenameModal());
|
||||
document.getElementById('gallery-rename-save').addEventListener('click', () => this.saveRename());
|
||||
document.getElementById('gallery-rename-input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.saveRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
this.closeRenameModal();
|
||||
}
|
||||
});
|
||||
document.getElementById('gallery-rename-input').addEventListener('input', () => {
|
||||
document.getElementById('gallery-rename-error').textContent = '';
|
||||
});
|
||||
|
||||
// Touch events for swipe on gallery image container
|
||||
const container = document.getElementById('gallery-image-container');
|
||||
container.addEventListener('touchstart', (e) => {
|
||||
this.galleryTouchStartX = e.changedTouches[0].screenX;
|
||||
this.galleryTouchStartY = e.changedTouches[0].screenY;
|
||||
}, { passive: true });
|
||||
container.addEventListener('touchend', (e) => {
|
||||
this.galleryTouchEndX = e.changedTouches[0].screenX;
|
||||
this.galleryTouchEndY = e.changedTouches[0].screenY;
|
||||
this.handleGallerySwipe();
|
||||
}, { passive: true });
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!this.galleryOpen) return;
|
||||
if (e.key === 'ArrowLeft') this.galleryPrev();
|
||||
else if (e.key === 'ArrowRight') this.galleryNext();
|
||||
else if (e.key === 'Escape') this.closeGallery();
|
||||
});
|
||||
},
|
||||
|
||||
isImageFile(item) {
|
||||
if (item.type !== 'file') return false;
|
||||
if (item.content_type && item.content_type.startsWith('image/')) return true;
|
||||
// Extension fallback
|
||||
const ext = (item.name || '').split('.').pop().toLowerCase();
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'bmp', 'tiff', 'tif', 'svg'].includes(ext);
|
||||
},
|
||||
|
||||
async loadDirectory(path) {
|
||||
const fileList = document.getElementById('file-list');
|
||||
fileList.innerHTML = '<p class="loading">Loading...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/files/list?path=${encodeURIComponent(path)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load directory');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.currentPath = path;
|
||||
this.saveCurrentPath();
|
||||
this.renderBreadcrumb(path);
|
||||
this.renderFileList(data.items || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading directory:', error);
|
||||
fileList.innerHTML = `<p class="error-state">Failed to load directory: ${error.message}</p>`;
|
||||
}
|
||||
},
|
||||
|
||||
renderBreadcrumb(path) {
|
||||
const breadcrumb = document.getElementById('breadcrumb');
|
||||
const parts = path.split('/').filter(p => p);
|
||||
|
||||
let html = '<a href="#" class="breadcrumb-item" data-path="/">Home</a>';
|
||||
|
||||
let currentPath = '';
|
||||
parts.forEach((part, index) => {
|
||||
currentPath += '/' + part;
|
||||
html += ' / ';
|
||||
if (index === parts.length - 1) {
|
||||
html += `<span class="breadcrumb-item current">${part}</span>`;
|
||||
} else {
|
||||
html += `<a href="#" class="breadcrumb-item" data-path="${currentPath}">${part}</a>`;
|
||||
}
|
||||
});
|
||||
|
||||
breadcrumb.innerHTML = html;
|
||||
|
||||
// Add click handlers to breadcrumb links
|
||||
breadcrumb.querySelectorAll('a.breadcrumb-item').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const path = link.dataset.path;
|
||||
this.loadDirectory(path);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
renderFileList(items) {
|
||||
const fileList = document.getElementById('file-list');
|
||||
|
||||
if (items.length === 0) {
|
||||
fileList.innerHTML = '<p class="empty-state">This folder is empty</p>';
|
||||
this.renderSelectButton();
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort: directories first, then files, alphabetically
|
||||
const sortedItems = items.sort((a, b) => {
|
||||
if (a.type === b.type) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
return a.type === 'directory' ? -1 : 1;
|
||||
});
|
||||
|
||||
// Build gallery images list from sorted items
|
||||
this.galleryImages = [];
|
||||
const imageIndexMap = {};
|
||||
sortedItems.forEach((item, i) => {
|
||||
if (this.isImageFile(item)) {
|
||||
imageIndexMap[i] = this.galleryImages.length;
|
||||
this.galleryImages.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
let html = '<div class="file-items">';
|
||||
|
||||
sortedItems.forEach((item, i) => {
|
||||
const isImage = this.isImageFile(item);
|
||||
let icon;
|
||||
if (item.type === 'directory') {
|
||||
icon = '📁';
|
||||
} else if (isImage) {
|
||||
icon = '🖼️';
|
||||
} else {
|
||||
icon = '📄';
|
||||
}
|
||||
const sizeText = item.type === 'file' ? this.formatSize(item.size) : '';
|
||||
const galleryAttr = isImage ? ` data-gallery-index="${imageIndexMap[i]}"` : '';
|
||||
|
||||
html += `
|
||||
<div class="file-item ${item.type}" data-path="${item.path}" data-type="${item.type}"${galleryAttr}>
|
||||
<span class="file-icon">${icon}</span>
|
||||
<div class="file-info">
|
||||
<div class="file-name">${this.escapeHtml(item.name)}</div>
|
||||
${sizeText ? `<div class="file-size">${sizeText}</div>` : ''}
|
||||
</div>
|
||||
${item.type === 'directory' ? '<span class="chevron">›</span>' : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
fileList.innerHTML = html;
|
||||
|
||||
// Add click handlers
|
||||
fileList.querySelectorAll('.file-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const path = item.dataset.path;
|
||||
const type = item.dataset.type;
|
||||
|
||||
if (type === 'directory') {
|
||||
this.loadDirectory(path);
|
||||
} else if (item.dataset.galleryIndex !== undefined) {
|
||||
this.openGallery(parseInt(item.dataset.galleryIndex, 10));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.renderSelectButton();
|
||||
},
|
||||
|
||||
renderSelectButton() {
|
||||
const fileList = document.getElementById('file-list');
|
||||
const selectBtn = document.createElement('button');
|
||||
selectBtn.className = 'btn btn-primary btn-select-folder';
|
||||
selectBtn.textContent = 'Select This Folder as Upload Destination';
|
||||
selectBtn.addEventListener('click', () => this.selectCurrentFolder());
|
||||
fileList.appendChild(selectBtn);
|
||||
},
|
||||
|
||||
async selectCurrentFolder() {
|
||||
localStorage.setItem('nextsnap_upload_path', this.currentPath);
|
||||
|
||||
const selectBtn = document.querySelector('.btn-select-folder');
|
||||
const originalText = selectBtn.textContent;
|
||||
selectBtn.textContent = '✓ Selected!';
|
||||
selectBtn.disabled = true;
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = '/capture';
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
async createNewFolder() {
|
||||
const folderName = prompt('Enter folder name:');
|
||||
|
||||
if (!folderName || !folderName.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedName = folderName.trim();
|
||||
|
||||
if (/[\/\\?*"|<>:]/.test(trimmedName)) {
|
||||
alert('Folder name contains invalid characters');
|
||||
return;
|
||||
}
|
||||
|
||||
const newPath = this.currentPath === '/'
|
||||
? '/' + trimmedName
|
||||
: this.currentPath + '/' + trimmedName;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/files/mkdir', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path: newPath })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to create folder');
|
||||
}
|
||||
|
||||
this.loadDirectory(this.currentPath);
|
||||
} catch (error) {
|
||||
console.error('Error creating folder:', error);
|
||||
alert('Failed to create folder: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Gallery methods ----
|
||||
|
||||
openGallery(index) {
|
||||
if (this.galleryImages.length === 0) return;
|
||||
this.galleryOpen = true;
|
||||
this.galleryIndex = index;
|
||||
this.galleryPreloaded = {};
|
||||
|
||||
document.body.classList.add('gallery-open');
|
||||
document.getElementById('gallery-overlay').style.display = 'flex';
|
||||
|
||||
this.displayGalleryImage(index);
|
||||
},
|
||||
|
||||
closeGallery() {
|
||||
this.galleryOpen = false;
|
||||
document.body.classList.remove('gallery-open');
|
||||
document.getElementById('gallery-overlay').style.display = 'none';
|
||||
|
||||
// Revoke preloaded object URLs
|
||||
Object.values(this.galleryPreloaded).forEach(url => {
|
||||
if (url && url.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||
});
|
||||
this.galleryPreloaded = {};
|
||||
|
||||
// Clear image src
|
||||
const img = document.getElementById('gallery-image');
|
||||
if (img.src && img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);
|
||||
img.src = '';
|
||||
},
|
||||
|
||||
async displayGalleryImage(index) {
|
||||
if (index < 0 || index >= this.galleryImages.length) return;
|
||||
this.galleryIndex = index;
|
||||
const item = this.galleryImages[index];
|
||||
|
||||
const img = document.getElementById('gallery-image');
|
||||
const spinner = document.getElementById('gallery-spinner');
|
||||
|
||||
// Show spinner, hide image
|
||||
spinner.style.display = 'flex';
|
||||
img.style.display = 'none';
|
||||
|
||||
// Update counter and filename
|
||||
document.getElementById('gallery-counter').textContent = (index + 1) + ' / ' + this.galleryImages.length;
|
||||
document.getElementById('gallery-filename').textContent = item.name;
|
||||
|
||||
// Update nav button states
|
||||
document.getElementById('gallery-prev-btn').disabled = (index === 0);
|
||||
document.getElementById('gallery-next-btn').disabled = (index === this.galleryImages.length - 1);
|
||||
|
||||
// Load image
|
||||
let src = this.galleryPreloaded[index];
|
||||
if (!src) {
|
||||
src = await this.fetchGalleryImage(item);
|
||||
this.galleryPreloaded[index] = src;
|
||||
}
|
||||
|
||||
// Only update if we're still on this index (user may have swiped away)
|
||||
if (this.galleryIndex !== index) return;
|
||||
|
||||
img.onload = () => {
|
||||
spinner.style.display = 'none';
|
||||
img.style.display = 'block';
|
||||
};
|
||||
img.onerror = () => {
|
||||
spinner.style.display = 'none';
|
||||
img.style.display = 'block';
|
||||
};
|
||||
img.src = src;
|
||||
|
||||
// Preload adjacent
|
||||
this.preloadGalleryAdjacent(index);
|
||||
},
|
||||
|
||||
async fetchGalleryImage(item) {
|
||||
const path = item.path || (this.currentPath + '/' + item.name);
|
||||
const url = '/api/files/thumbnail?path=' + encodeURIComponent(path) + '&size=1024';
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Failed to fetch image');
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (error) {
|
||||
console.error('Error fetching gallery image:', error);
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
preloadGalleryAdjacent(index) {
|
||||
const toPreload = [index - 1, index + 1, index + 2];
|
||||
toPreload.forEach(async (i) => {
|
||||
if (i >= 0 && i < this.galleryImages.length && !this.galleryPreloaded[i]) {
|
||||
this.galleryPreloaded[i] = await this.fetchGalleryImage(this.galleryImages[i]);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
galleryPrev() {
|
||||
if (this.galleryIndex > 0) {
|
||||
this.displayGalleryImage(this.galleryIndex - 1);
|
||||
}
|
||||
},
|
||||
|
||||
galleryNext() {
|
||||
if (this.galleryIndex < this.galleryImages.length - 1) {
|
||||
this.displayGalleryImage(this.galleryIndex + 1);
|
||||
}
|
||||
},
|
||||
|
||||
handleGallerySwipe() {
|
||||
const deltaX = this.galleryTouchEndX - this.galleryTouchStartX;
|
||||
const deltaY = this.galleryTouchEndY - this.galleryTouchStartY;
|
||||
|
||||
// Ignore if vertical swipe is dominant
|
||||
if (Math.abs(deltaY) > Math.abs(deltaX)) return;
|
||||
// Ignore if too small
|
||||
if (Math.abs(deltaX) < 50) return;
|
||||
|
||||
if (deltaX > 0) this.galleryPrev();
|
||||
else this.galleryNext();
|
||||
},
|
||||
|
||||
openRenameModal() {
|
||||
const item = this.galleryImages[this.galleryIndex];
|
||||
if (!item) return;
|
||||
|
||||
const name = item.name;
|
||||
const lastDot = name.lastIndexOf('.');
|
||||
let basename, ext;
|
||||
if (lastDot > 0) {
|
||||
basename = name.substring(0, lastDot);
|
||||
ext = name.substring(lastDot);
|
||||
} else {
|
||||
basename = name;
|
||||
ext = '';
|
||||
}
|
||||
|
||||
document.getElementById('gallery-rename-input').value = basename;
|
||||
document.getElementById('gallery-rename-ext').textContent = ext;
|
||||
document.getElementById('gallery-rename-error').textContent = '';
|
||||
document.getElementById('gallery-rename-backdrop').style.display = 'flex';
|
||||
|
||||
// Focus input after display
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById('gallery-rename-input');
|
||||
input.focus();
|
||||
input.select();
|
||||
}, 100);
|
||||
},
|
||||
|
||||
closeRenameModal() {
|
||||
document.getElementById('gallery-rename-backdrop').style.display = 'none';
|
||||
},
|
||||
|
||||
async saveRename() {
|
||||
const item = this.galleryImages[this.galleryIndex];
|
||||
if (!item) return;
|
||||
|
||||
const input = document.getElementById('gallery-rename-input');
|
||||
const ext = document.getElementById('gallery-rename-ext').textContent;
|
||||
const errorEl = document.getElementById('gallery-rename-error');
|
||||
const newBasename = input.value.trim();
|
||||
|
||||
// Validate
|
||||
if (!newBasename) {
|
||||
errorEl.textContent = 'Filename cannot be empty';
|
||||
return;
|
||||
}
|
||||
if (/[\/\\?*"|<>:]/.test(newBasename)) {
|
||||
errorEl.textContent = 'Invalid characters in filename';
|
||||
return;
|
||||
}
|
||||
if (newBasename.length > 200) {
|
||||
errorEl.textContent = 'Filename too long (max 200 characters)';
|
||||
return;
|
||||
}
|
||||
|
||||
const newName = newBasename + ext;
|
||||
|
||||
// If name hasn't changed, just close
|
||||
if (newName === item.name) {
|
||||
this.closeRenameModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById('gallery-rename-save');
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
|
||||
try {
|
||||
const sourcePath = item.path || (this.currentPath + '/' + item.name);
|
||||
const destPath = this.currentPath + '/' + newName;
|
||||
|
||||
const response = await fetch('/api/files/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source: sourcePath, destination: destPath })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Rename failed');
|
||||
}
|
||||
|
||||
// Update local state
|
||||
item.name = newName;
|
||||
item.path = destPath;
|
||||
document.getElementById('gallery-filename').textContent = newName;
|
||||
|
||||
this.closeRenameModal();
|
||||
this.showGalleryToast('File renamed', 'success');
|
||||
|
||||
// Refresh the file list in the background
|
||||
this.loadDirectory(this.currentPath);
|
||||
} catch (error) {
|
||||
console.error('Error renaming file:', error);
|
||||
errorEl.textContent = error.message;
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Save';
|
||||
}
|
||||
},
|
||||
|
||||
showGalleryToast(msg, type) {
|
||||
const toast = document.getElementById('gallery-toast');
|
||||
toast.textContent = msg;
|
||||
toast.className = 'gallery-toast ' + type;
|
||||
toast.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
toast.style.display = 'none';
|
||||
}, 2500);
|
||||
},
|
||||
|
||||
formatSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', '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];
|
||||
},
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
saveCurrentPath() {
|
||||
localStorage.setItem('nextsnap_current_browse_path', this.currentPath);
|
||||
},
|
||||
|
||||
loadCurrentPath() {
|
||||
const saved = localStorage.getItem('nextsnap_current_browse_path');
|
||||
if (saved) {
|
||||
this.currentPath = saved;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => FileBrowser.init());
|
||||
} else {
|
||||
FileBrowser.init();
|
||||
}
|
||||
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;
|
||||
288
app/static/js/reviewer.js
Normal file
288
app/static/js/reviewer.js
Normal file
@@ -0,0 +1,288 @@
|
||||
// NextSnap - Photo Reviewer with Swipe Navigation and Rename
|
||||
'use strict';
|
||||
|
||||
const Reviewer = {
|
||||
mode: null,
|
||||
photos: [],
|
||||
currentIndex: 0,
|
||||
username: null,
|
||||
currentPath: null,
|
||||
touchStartX: 0,
|
||||
touchStartY: 0,
|
||||
touchEndX: 0,
|
||||
touchEndY: 0,
|
||||
swipeThreshold: 50,
|
||||
preloadedImages: {},
|
||||
isEditing: false,
|
||||
originalFilename: null,
|
||||
|
||||
init(mode, username, photos, currentPath) {
|
||||
this.mode = mode;
|
||||
this.username = username;
|
||||
this.photos = photos;
|
||||
this.currentPath = currentPath || null;
|
||||
this.currentIndex = 0;
|
||||
|
||||
if (this.photos.length === 0) {
|
||||
this.showEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setupEventListeners();
|
||||
this.displayPhoto(0);
|
||||
this.preloadAdjacentPhotos(0);
|
||||
this.updatePosition();
|
||||
},
|
||||
|
||||
setupEventListeners() {
|
||||
const viewer = document.getElementById('photo-viewer');
|
||||
|
||||
viewer.addEventListener('touchstart', (e) => {
|
||||
this.touchStartX = e.changedTouches[0].screenX;
|
||||
this.touchStartY = e.changedTouches[0].screenY;
|
||||
}, { passive: true });
|
||||
|
||||
viewer.addEventListener('touchend', (e) => {
|
||||
this.touchEndX = e.changedTouches[0].screenX;
|
||||
this.touchEndY = e.changedTouches[0].screenY;
|
||||
this.handleSwipe();
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowLeft') this.previousPhoto();
|
||||
else if (e.key === 'ArrowRight') this.nextPhoto();
|
||||
else if (e.key === 'Escape') this.exit();
|
||||
});
|
||||
|
||||
document.getElementById('prev-btn').addEventListener('click', () => this.previousPhoto());
|
||||
document.getElementById('next-btn').addEventListener('click', () => this.nextPhoto());
|
||||
document.getElementById('done-btn').addEventListener('click', () => this.exit());
|
||||
|
||||
const filenameInput = document.getElementById('filename-input');
|
||||
|
||||
filenameInput.addEventListener('focus', () => {
|
||||
this.isEditing = true;
|
||||
this.originalFilename = filenameInput.value;
|
||||
});
|
||||
|
||||
filenameInput.addEventListener('blur', () => {
|
||||
this.saveFilename();
|
||||
});
|
||||
|
||||
filenameInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
filenameInput.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
filenameInput.value = this.originalFilename;
|
||||
filenameInput.blur();
|
||||
this.isEditing = false;
|
||||
}
|
||||
});
|
||||
|
||||
viewer.addEventListener('click', (e) => {
|
||||
if (this.isEditing && !filenameInput.contains(e.target)) {
|
||||
filenameInput.blur();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
handleSwipe() {
|
||||
const deltaX = this.touchEndX - this.touchStartX;
|
||||
const deltaY = this.touchEndY - this.touchStartY;
|
||||
|
||||
if (Math.abs(deltaY) > Math.abs(deltaX)) return;
|
||||
if (Math.abs(deltaX) < this.swipeThreshold) return;
|
||||
|
||||
if (deltaX > 0) this.previousPhoto();
|
||||
else this.nextPhoto();
|
||||
},
|
||||
|
||||
async displayPhoto(index) {
|
||||
if (index < 0 || index >= this.photos.length) return;
|
||||
|
||||
if (this.isEditing) await this.saveFilename();
|
||||
|
||||
this.currentIndex = index;
|
||||
const photo = this.photos[index];
|
||||
const img = document.getElementById('current-photo');
|
||||
const spinner = document.getElementById('photo-spinner');
|
||||
|
||||
spinner.style.display = 'block';
|
||||
img.style.display = 'none';
|
||||
|
||||
if (this.mode === 'local') {
|
||||
img.src = URL.createObjectURL(photo.blob);
|
||||
} else {
|
||||
img.src = this.preloadedImages[index] || await this.fetchThumbnail(photo);
|
||||
}
|
||||
|
||||
img.onload = () => {
|
||||
spinner.style.display = 'none';
|
||||
img.style.display = 'block';
|
||||
};
|
||||
|
||||
const filenameInput = document.getElementById('filename-input');
|
||||
filenameInput.value = photo.filename.replace(/\.jpg$/i, '');
|
||||
|
||||
this.updatePosition();
|
||||
this.updateNavigationButtons();
|
||||
this.preloadAdjacentPhotos(index);
|
||||
},
|
||||
|
||||
async fetchThumbnail(photo) {
|
||||
const path = this.currentPath + '/' + photo.filename;
|
||||
const url = '/api/files/thumbnail?path=' + encodeURIComponent(path) + '&size=512';
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Failed to fetch thumbnail');
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (error) {
|
||||
console.error('Error fetching thumbnail:', error);
|
||||
return '/static/icons/icon-192.png';
|
||||
}
|
||||
},
|
||||
|
||||
preloadAdjacentPhotos(index) {
|
||||
const toPreload = [index - 1, index + 1];
|
||||
if (this.mode === 'remote') toPreload.push(index - 2, index + 2);
|
||||
|
||||
toPreload.forEach(async (i) => {
|
||||
if (i >= 0 && i < this.photos.length && !this.preloadedImages[i]) {
|
||||
const photo = this.photos[i];
|
||||
if (this.mode === 'local') {
|
||||
this.preloadedImages[i] = URL.createObjectURL(photo.blob);
|
||||
} else {
|
||||
this.preloadedImages[i] = await this.fetchThumbnail(photo);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
updatePosition() {
|
||||
const position = document.getElementById('photo-position');
|
||||
position.textContent = (this.currentIndex + 1) + ' / ' + this.photos.length;
|
||||
},
|
||||
|
||||
updateNavigationButtons() {
|
||||
document.getElementById('prev-btn').disabled = this.currentIndex === 0;
|
||||
document.getElementById('next-btn').disabled = this.currentIndex === this.photos.length - 1;
|
||||
},
|
||||
|
||||
previousPhoto() {
|
||||
if (this.currentIndex > 0) this.displayPhoto(this.currentIndex - 1);
|
||||
},
|
||||
|
||||
nextPhoto() {
|
||||
if (this.currentIndex < this.photos.length - 1) this.displayPhoto(this.currentIndex + 1);
|
||||
},
|
||||
|
||||
async saveFilename() {
|
||||
if (!this.isEditing) return;
|
||||
|
||||
const filenameInput = document.getElementById('filename-input');
|
||||
const newBasename = filenameInput.value.trim();
|
||||
const photo = this.photos[this.currentIndex];
|
||||
const currentBasename = photo.filename.replace(/\.jpg$/i, '');
|
||||
|
||||
if (newBasename === currentBasename) {
|
||||
this.isEditing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = this.validateFilename(newBasename);
|
||||
if (!validation.valid) {
|
||||
this.showError(validation.error);
|
||||
filenameInput.value = currentBasename;
|
||||
this.isEditing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const newFilename = newBasename + '.jpg';
|
||||
const saveSpinner = document.getElementById('save-spinner');
|
||||
saveSpinner.style.display = 'inline-block';
|
||||
filenameInput.disabled = true;
|
||||
|
||||
try {
|
||||
if (this.mode === 'local') {
|
||||
await Storage.updatePhoto(photo.id, { filename: newFilename });
|
||||
photo.filename = newFilename;
|
||||
console.log('Updated local filename to: ' + newFilename);
|
||||
this.showSuccess('Filename updated');
|
||||
} else {
|
||||
if (!navigator.onLine) throw new Error('Offline - cannot rename remote files');
|
||||
|
||||
const oldPath = this.currentPath + '/' + photo.filename;
|
||||
const newPath = this.currentPath + '/' + newFilename;
|
||||
|
||||
const response = await fetch('/api/files/rename', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sourcePath: oldPath, destPath: newPath })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Rename failed');
|
||||
}
|
||||
|
||||
photo.filename = newFilename;
|
||||
console.log('Renamed remote file to: ' + newFilename);
|
||||
this.showSuccess('File renamed on server');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving filename:', error);
|
||||
this.showError(error.message);
|
||||
filenameInput.value = currentBasename;
|
||||
} finally {
|
||||
saveSpinner.style.display = 'none';
|
||||
filenameInput.disabled = false;
|
||||
this.isEditing = false;
|
||||
}
|
||||
},
|
||||
|
||||
validateFilename(filename) {
|
||||
if (!filename || filename.length === 0) {
|
||||
return { valid: false, error: 'Filename cannot be empty' };
|
||||
}
|
||||
const invalidChars = /[\/\\?*"|<>:]/;
|
||||
if (invalidChars.test(filename)) {
|
||||
return { valid: false, error: 'Invalid characters in filename' };
|
||||
}
|
||||
if (filename.length > 200) {
|
||||
return { valid: false, error: 'Filename too long (max 200 characters)' };
|
||||
}
|
||||
return { valid: true };
|
||||
},
|
||||
|
||||
showError(message) {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = '❌ ' + message;
|
||||
toast.className = 'toast error';
|
||||
toast.style.display = 'block';
|
||||
setTimeout(() => { toast.style.display = 'none'; }, 3000);
|
||||
},
|
||||
|
||||
showSuccess(message) {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = '✓ ' + message;
|
||||
toast.className = 'toast success';
|
||||
toast.style.display = 'block';
|
||||
setTimeout(() => { toast.style.display = 'none'; }, 2000);
|
||||
},
|
||||
|
||||
showEmptyState() {
|
||||
const viewer = document.getElementById('photo-viewer');
|
||||
viewer.innerHTML = '<div class="empty-state"><p>No photos to review</p><button class="btn btn-primary" onclick="history.back()">Go Back</button></div>';
|
||||
},
|
||||
|
||||
exit() {
|
||||
if (this.isEditing) this.saveFilename();
|
||||
Object.values(this.preloadedImages).forEach(url => URL.revokeObjectURL(url));
|
||||
history.back();
|
||||
}
|
||||
};
|
||||
|
||||
window.Reviewer = Reviewer;
|
||||
152
app/static/js/storage.js
Normal file
152
app/static/js/storage.js
Normal file
@@ -0,0 +1,152 @@
|
||||
// NextSnap - IndexedDB Storage using Dexie.js
|
||||
'use strict';
|
||||
|
||||
const Storage = {
|
||||
db: null,
|
||||
|
||||
init() {
|
||||
// Initialize Dexie database
|
||||
this.db = new Dexie('nextsnap');
|
||||
|
||||
// Define schema with compound indexes
|
||||
this.db.version(1).stores({
|
||||
photos: '++id, username, timestamp, filename, targetPath, status, [username+status], [username+timestamp]',
|
||||
settings: '++id, username, [username+key]'
|
||||
});
|
||||
|
||||
return this.db.open();
|
||||
},
|
||||
|
||||
async savePhoto(photoData) {
|
||||
/**
|
||||
* Save a photo to IndexedDB
|
||||
* photoData: {
|
||||
* username: string,
|
||||
* timestamp: number,
|
||||
* filename: string,
|
||||
* targetPath: string,
|
||||
* blob: Blob,
|
||||
* status: 'pending' | 'uploading' | 'uploaded' | 'verified'
|
||||
* retryCount: number,
|
||||
* lastError: string
|
||||
* }
|
||||
*/
|
||||
const id = await this.db.photos.add({
|
||||
username: photoData.username,
|
||||
timestamp: photoData.timestamp,
|
||||
filename: photoData.filename,
|
||||
targetPath: photoData.targetPath,
|
||||
blob: photoData.blob,
|
||||
status: photoData.status || 'pending',
|
||||
retryCount: photoData.retryCount || 0,
|
||||
lastError: photoData.lastError || null
|
||||
});
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
async getPhoto(id) {
|
||||
return await this.db.photos.get(id);
|
||||
},
|
||||
|
||||
async getAllPhotos(username = null) {
|
||||
if (username) {
|
||||
return await this.db.photos
|
||||
.where('username').equals(username)
|
||||
.reverse()
|
||||
.sortBy('timestamp');
|
||||
}
|
||||
return await this.db.photos.reverse().sortBy('timestamp');
|
||||
},
|
||||
|
||||
async getPendingPhotos(username = null) {
|
||||
if (username) {
|
||||
return await this.db.photos
|
||||
.where('[username+status]')
|
||||
.equals([username, 'pending'])
|
||||
.sortBy('timestamp');
|
||||
}
|
||||
return await this.db.photos
|
||||
.where('status').equals('pending')
|
||||
.sortBy('timestamp');
|
||||
},
|
||||
|
||||
async getRecentPhotos(username, limit = 5) {
|
||||
return await this.db.photos
|
||||
.where('username').equals(username)
|
||||
.reverse()
|
||||
.limit(limit)
|
||||
.sortBy('timestamp');
|
||||
},
|
||||
|
||||
async updatePhoto(id, updates) {
|
||||
return await this.db.photos.update(id, updates);
|
||||
},
|
||||
|
||||
async deletePhoto(id) {
|
||||
return await this.db.photos.delete(id);
|
||||
},
|
||||
|
||||
async getPhotoCount(username = null, status = null) {
|
||||
let collection = this.db.photos;
|
||||
|
||||
if (username && status) {
|
||||
return await collection
|
||||
.where('[username+status]')
|
||||
.equals([username, status])
|
||||
.count();
|
||||
} else if (username) {
|
||||
return await collection
|
||||
.where('username').equals(username)
|
||||
.count();
|
||||
} else if (status) {
|
||||
return await collection
|
||||
.where('status').equals(status)
|
||||
.count();
|
||||
}
|
||||
|
||||
return await collection.count();
|
||||
},
|
||||
|
||||
async saveSetting(username, key, value) {
|
||||
// Check if setting exists
|
||||
const existing = await this.db.settings
|
||||
.where('[username+key]')
|
||||
.equals([username, key])
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await this.db.settings.update(existing.id, { value: value });
|
||||
} else {
|
||||
await this.db.settings.add({
|
||||
username: username,
|
||||
key: key,
|
||||
value: value
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async getSetting(username, key, defaultValue = null) {
|
||||
const setting = await this.db.settings
|
||||
.where('[username+key]')
|
||||
.equals([username, key])
|
||||
.first();
|
||||
|
||||
return setting ? setting.value : defaultValue;
|
||||
},
|
||||
|
||||
async clearVerifiedPhotos(username = null) {
|
||||
if (username) {
|
||||
return await this.db.photos
|
||||
.where('[username+status]')
|
||||
.equals([username, 'verified'])
|
||||
.delete();
|
||||
}
|
||||
return await this.db.photos
|
||||
.where('status').equals('verified')
|
||||
.delete();
|
||||
}
|
||||
};
|
||||
|
||||
// Make Storage globally available
|
||||
window.Storage = Storage;
|
||||
300
app/static/js/sync.js
Normal file
300
app/static/js/sync.js
Normal file
@@ -0,0 +1,300 @@
|
||||
// NextSnap - Sync Engine
|
||||
'use strict';
|
||||
|
||||
console.log('[SYNC] Loading sync.js...');
|
||||
|
||||
const Sync = {
|
||||
currentUsername: null,
|
||||
isOnline: navigator.onLine,
|
||||
isSyncing: false,
|
||||
MAX_RETRIES: 5,
|
||||
VERIFY_DELAY_MS: 800,
|
||||
|
||||
init(username) {
|
||||
this.currentUsername = username;
|
||||
this.setupEventListeners();
|
||||
console.log('[SYNC] Initialized for:', username);
|
||||
|
||||
if (this.isOnline) {
|
||||
this.triggerSync();
|
||||
}
|
||||
},
|
||||
|
||||
setupEventListeners() {
|
||||
window.addEventListener('online', () => {
|
||||
console.log('[SYNC] Network online');
|
||||
this.isOnline = true;
|
||||
this.triggerSync();
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
console.log('[SYNC] Network offline');
|
||||
this.isOnline = false;
|
||||
this.isSyncing = false;
|
||||
});
|
||||
},
|
||||
|
||||
// Prevent page navigation during active upload
|
||||
_setUploading(active) {
|
||||
if (active) {
|
||||
this._beforeUnloadHandler = (e) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = 'Upload in progress - leaving will cancel it.';
|
||||
return e.returnValue;
|
||||
};
|
||||
window.addEventListener('beforeunload', this._beforeUnloadHandler);
|
||||
} else {
|
||||
if (this._beforeUnloadHandler) {
|
||||
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
|
||||
this._beforeUnloadHandler = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async triggerSync() {
|
||||
if (!this.isOnline || this.isSyncing) {
|
||||
console.log('[SYNC] Skip sync - online:', this.isOnline, 'syncing:', this.isSyncing);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SYNC] Starting sync...');
|
||||
this.isSyncing = true;
|
||||
|
||||
try {
|
||||
await this.processQueue();
|
||||
} catch (error) {
|
||||
console.error('[SYNC] Error:', error);
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
this._setUploading(false);
|
||||
}
|
||||
},
|
||||
|
||||
async processQueue() {
|
||||
const pendingPhotos = await Storage.db.photos
|
||||
.where('username').equals(this.currentUsername)
|
||||
.and(photo => photo.status === 'pending' || photo.status === 'uploading')
|
||||
.sortBy('timestamp');
|
||||
|
||||
console.log('[SYNC] Found', pendingPhotos.length, 'photos to process');
|
||||
|
||||
if (pendingPhotos.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const photo of pendingPhotos) {
|
||||
if (!this.isOnline) {
|
||||
console.log('[SYNC] Lost connection, stopping');
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip photos that have exceeded max retries
|
||||
const retryCount = photo.retryCount || 0;
|
||||
if (retryCount >= this.MAX_RETRIES) {
|
||||
console.warn('[SYNC] Skipping photo (max retries reached):', photo.filename, 'retries:', retryCount);
|
||||
await Storage.updatePhoto(photo.id, { status: 'failed' });
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.uploadPhoto(photo);
|
||||
}
|
||||
},
|
||||
|
||||
async uploadPhoto(photo) {
|
||||
const retryCount = photo.retryCount || 0;
|
||||
|
||||
try {
|
||||
console.log('[SYNC] Uploading:', photo.filename, '(attempt', retryCount + 1, 'of', this.MAX_RETRIES + ')');
|
||||
|
||||
// Validate blob before attempting upload
|
||||
if (!photo.blob || !(photo.blob instanceof Blob) || photo.blob.size === 0) {
|
||||
throw new Error('Photo data is missing or corrupted - please delete and re-capture');
|
||||
}
|
||||
|
||||
await Storage.updatePhoto(photo.id, { status: 'uploading' });
|
||||
|
||||
// Prevent page navigation during upload
|
||||
this._setUploading(true);
|
||||
|
||||
// Only check for duplicates on retries (skip on first attempt to reduce latency)
|
||||
if (retryCount > 0) {
|
||||
const fullPath = photo.targetPath.replace(/\/$/, '') + '/' + photo.filename;
|
||||
const alreadyExists = await this.checkFileExists(fullPath);
|
||||
|
||||
if (alreadyExists) {
|
||||
console.log('[SYNC] File already exists on server, skipping upload:', fullPath);
|
||||
await Storage.updatePhoto(photo.id, {
|
||||
status: 'verified',
|
||||
blob: null,
|
||||
completedAt: Date.now()
|
||||
});
|
||||
await this.pruneHistory();
|
||||
this._setUploading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Resize if over 10MB
|
||||
let uploadBlob = photo.blob;
|
||||
if (uploadBlob.size > 10 * 1024 * 1024) {
|
||||
console.log('[SYNC] Photo too large (' + (uploadBlob.size / 1024 / 1024).toFixed(1) + 'MB), resizing...');
|
||||
uploadBlob = await this.resizeImage(uploadBlob, 10 * 1024 * 1024);
|
||||
console.log('[SYNC] Resized to ' + (uploadBlob.size / 1024 / 1024).toFixed(1) + 'MB');
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', uploadBlob, photo.filename);
|
||||
|
||||
const uploadUrl = '/api/files/upload?path=' + encodeURIComponent(photo.targetPath);
|
||||
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
let errorMsg = 'Upload failed';
|
||||
try {
|
||||
const errData = await uploadResponse.json();
|
||||
errorMsg = errData.error || errorMsg;
|
||||
} catch (e) {
|
||||
errorMsg = 'Upload failed (HTTP ' + uploadResponse.status + ')';
|
||||
}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const uploadResult = await uploadResponse.json();
|
||||
console.log('[SYNC] Upload successful:', uploadResult.path);
|
||||
|
||||
await Storage.updatePhoto(photo.id, { status: 'uploaded' });
|
||||
|
||||
// Wait before verifying to allow server-side processing
|
||||
await this.delay(this.VERIFY_DELAY_MS);
|
||||
|
||||
const verifyUrl = '/api/files/verify?path=' + encodeURIComponent(uploadResult.path);
|
||||
const verifyResponse = await fetch(verifyUrl);
|
||||
|
||||
if (!verifyResponse.ok) {
|
||||
throw new Error('Verification failed');
|
||||
}
|
||||
|
||||
const verifyResult = await verifyResponse.json();
|
||||
|
||||
if (!verifyResult.exists) {
|
||||
throw new Error('File not found on server');
|
||||
}
|
||||
|
||||
console.log('[SYNC] Verified:', uploadResult.path);
|
||||
|
||||
// Keep record but strip blob to save storage
|
||||
await Storage.updatePhoto(photo.id, {
|
||||
status: 'verified',
|
||||
blob: null,
|
||||
completedAt: Date.now()
|
||||
});
|
||||
|
||||
console.log('[SYNC] Upload complete:', photo.id);
|
||||
|
||||
// Prune old completed entries beyond 20
|
||||
await this.pruneHistory();
|
||||
|
||||
// Clear navigation guard after successful upload
|
||||
this._setUploading(false);
|
||||
|
||||
} catch (error) {
|
||||
this._setUploading(false);
|
||||
console.error('[SYNC] Upload failed:', error, '(attempt', retryCount + 1 + ')');
|
||||
await Storage.updatePhoto(photo.id, {
|
||||
status: 'pending',
|
||||
retryCount: retryCount + 1,
|
||||
lastError: error.message,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async resizeImage(blob, maxBytes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
let quality = 0.85;
|
||||
let scale = 1.0;
|
||||
|
||||
const attempt = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(img.width * scale);
|
||||
canvas.height = Math.round(img.height * scale);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob((result) => {
|
||||
if (!result) {
|
||||
reject(new Error('Failed to resize image'));
|
||||
return;
|
||||
}
|
||||
if (result.size <= maxBytes || (quality <= 0.4 && scale <= 0.3)) {
|
||||
resolve(result);
|
||||
} else {
|
||||
// Reduce quality first, then scale down
|
||||
if (quality > 0.5) {
|
||||
quality -= 0.1;
|
||||
} else {
|
||||
scale *= 0.8;
|
||||
quality = 0.7;
|
||||
}
|
||||
attempt();
|
||||
}
|
||||
}, 'image/jpeg', quality);
|
||||
};
|
||||
|
||||
attempt();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to load image for resize'));
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
},
|
||||
|
||||
async pruneHistory() {
|
||||
// Keep only the last 20 completed (verified/failed) entries per user
|
||||
const completed = await Storage.db.photos
|
||||
.where('username').equals(this.currentUsername)
|
||||
.and(p => p.status === 'verified' || p.status === 'failed')
|
||||
.sortBy('timestamp');
|
||||
|
||||
if (completed.length > 20) {
|
||||
const toDelete = completed.slice(0, completed.length - 20);
|
||||
for (const photo of toDelete) {
|
||||
await Storage.deletePhoto(photo.id);
|
||||
}
|
||||
console.log('[SYNC] Pruned', toDelete.length, 'old history entries');
|
||||
}
|
||||
},
|
||||
|
||||
async checkFileExists(path) {
|
||||
try {
|
||||
const verifyUrl = '/api/files/verify?path=' + encodeURIComponent(path);
|
||||
const response = await fetch(verifyUrl);
|
||||
if (!response.ok) return false;
|
||||
const result = await response.json();
|
||||
return result.exists === true;
|
||||
} catch (e) {
|
||||
// If we can't check, assume it doesn't exist and proceed with upload
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
};
|
||||
|
||||
window.SyncEngine = Sync;
|
||||
console.log('[SYNC] SyncEngine exported successfully');
|
||||
242
app/static/js/sync_broken_backup.js
Normal file
242
app/static/js/sync_broken_backup.js
Normal file
@@ -0,0 +1,242 @@
|
||||
// SYNC.JS VERSION 8 - LOADING
|
||||
console.log("[SYNC] Loading sync.js v8...");
|
||||
// NextSnap - Sync Engine with Upload Queue and Retry Logic
|
||||
'use strict';
|
||||
|
||||
const Sync = {
|
||||
currentUsername: null,
|
||||
isOnline: navigator.onLine,
|
||||
isSyncing: false,
|
||||
currentUpload: null,
|
||||
retryTimeouts: {},
|
||||
|
||||
// Exponential backoff delays (in milliseconds)
|
||||
retryDelays: [5000, 15000, 45000, 120000, 300000], // 5s, 15s, 45s, 2m, 5m
|
||||
maxRetryDelay: 300000, // Cap at 5 minutes
|
||||
|
||||
init(username) {
|
||||
this.currentUsername = username;
|
||||
this.setupEventListeners();
|
||||
|
||||
// Check for pending uploads on init
|
||||
if (this.isOnline) {
|
||||
this.triggerSync();
|
||||
}
|
||||
},
|
||||
|
||||
setupEventListeners() {
|
||||
// Listen for online/offline events
|
||||
window.addEventListener('online', () => {
|
||||
console.log('Network online - triggering sync');
|
||||
this.isOnline = true;
|
||||
this.updateConnectivityUI();
|
||||
this.triggerSync();
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
console.log('Network offline');
|
||||
this.isOnline = false;
|
||||
this.isSyncing = false;
|
||||
this.updateConnectivityUI();
|
||||
});
|
||||
},
|
||||
|
||||
async triggerSync() {
|
||||
if (!this.isOnline || this.isSyncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Starting sync...');
|
||||
this.isSyncing = true;
|
||||
this.updateConnectivityUI();
|
||||
|
||||
try {
|
||||
await this.processQueue();
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error);
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
this.updateConnectivityUI();
|
||||
}
|
||||
},
|
||||
|
||||
async processQueue() {
|
||||
// Get pending and uploading photos (retry stalled uploads)
|
||||
const pendingPhotos = await Storage.db.photos
|
||||
.where('username').equals(this.currentUsername)
|
||||
.and(photo => photo.status === 'pending' || photo.status === 'uploading')
|
||||
.sortBy('timestamp');
|
||||
|
||||
if (pendingPhotos.length === 0) {
|
||||
console.log('No pending photos to upload');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${pendingPhotos.length} photos to upload`);
|
||||
|
||||
// Process uploads sequentially (one at a time)
|
||||
for (const photo of pendingPhotos) {
|
||||
if (!this.isOnline) {
|
||||
console.log('Lost connection - stopping sync');
|
||||
break;
|
||||
}
|
||||
|
||||
await this.uploadPhoto(photo);
|
||||
}
|
||||
|
||||
// Update UI
|
||||
this.updatePendingCount();
|
||||
this.updateRecentPhotos();
|
||||
},
|
||||
|
||||
async uploadPhoto(photo) {
|
||||
this.currentUpload = photo;
|
||||
|
||||
try {
|
||||
console.log(`Uploading ${photo.filename}...`);
|
||||
|
||||
// Update status to uploading
|
||||
await Storage.updatePhoto(photo.id, { status: 'uploading' });
|
||||
this.updatePendingCount();
|
||||
|
||||
// Upload file
|
||||
const formData = new FormData();
|
||||
formData.append('file', photo.blob, photo.filename);
|
||||
|
||||
const uploadUrl = `/api/files/upload?path=${encodeURIComponent(photo.targetPath)}`;
|
||||
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
const error = await uploadResponse.json();
|
||||
throw new Error(error.error || 'Upload failed');
|
||||
}
|
||||
|
||||
const uploadResult = await uploadResponse.json();
|
||||
console.log(`Upload successful: ${uploadResult.path}`);
|
||||
|
||||
// Update status to uploaded
|
||||
await Storage.updatePhoto(photo.id, { status: 'uploaded' });
|
||||
|
||||
// Verify file exists on server
|
||||
const verifyUrl = `/api/files/verify?path=${encodeURIComponent(uploadResult.path)}`;
|
||||
|
||||
const verifyResponse = await fetch(verifyUrl);
|
||||
|
||||
if (!verifyResponse.ok) {
|
||||
throw new Error('Verification failed - file not found on server');
|
||||
}
|
||||
|
||||
const verifyResult = await verifyResponse.json();
|
||||
|
||||
if (!verifyResult.exists) {
|
||||
throw new Error('Verification failed - file does not exist');
|
||||
}
|
||||
|
||||
console.log(`Verification successful: ${uploadResult.path}`);
|
||||
|
||||
// Update status to verified
|
||||
await Storage.updatePhoto(photo.id, { status: 'verified' });
|
||||
|
||||
// Delete from IndexedDB (only after verification!)
|
||||
await Storage.deletePhoto(photo.id);
|
||||
console.log(`Deleted photo ${photo.id} from IndexedDB`);
|
||||
|
||||
// Clear any pending retry
|
||||
if (this.retryTimeouts[photo.id]) {
|
||||
clearTimeout(this.retryTimeouts[photo.id]);
|
||||
delete this.retryTimeouts[photo.id];
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error uploading ${photo.filename}:`, error);
|
||||
|
||||
// Handle upload failure
|
||||
await this.handleUploadFailure(photo, error.message);
|
||||
} finally {
|
||||
this.currentUpload = null;
|
||||
}
|
||||
},
|
||||
|
||||
async handleUploadFailure(photo, errorMessage) {
|
||||
const retryCount = (photo.retryCount || 0) + 1;
|
||||
|
||||
// Update photo with error info
|
||||
await Storage.updatePhoto(photo.id, {
|
||||
status: 'pending',
|
||||
retryCount: retryCount,
|
||||
lastError: errorMessage
|
||||
});
|
||||
|
||||
// Calculate retry delay using exponential backoff
|
||||
const delayIndex = Math.min(retryCount - 1, this.retryDelays.length - 1);
|
||||
const delay = this.retryDelays[delayIndex];
|
||||
|
||||
console.log(`Scheduling retry #${retryCount} in ${delay / 1000}s for ${photo.filename}`);
|
||||
|
||||
// Schedule retry
|
||||
if (this.retryTimeouts[photo.id]) {
|
||||
clearTimeout(this.retryTimeouts[photo.id]);
|
||||
}
|
||||
|
||||
this.retryTimeouts[photo.id] = setTimeout(() => {
|
||||
delete this.retryTimeouts[photo.id];
|
||||
|
||||
if (this.isOnline) {
|
||||
console.log(`Retrying upload for ${photo.filename}`);
|
||||
this.uploadPhoto(photo);
|
||||
}
|
||||
}, delay);
|
||||
},
|
||||
|
||||
|
||||
|
||||
async updateRecentPhotos() {
|
||||
if (typeof Camera !== 'undefined' && Camera.updateRecentPhotos) {
|
||||
await Camera.updateRecentPhotos();
|
||||
}
|
||||
},
|
||||
updateConnectivityUI() {
|
||||
const indicator = document.querySelector(".connectivity-indicator");
|
||||
if (!indicator) return;
|
||||
indicator.classList.remove("online", "offline", "syncing");
|
||||
if (!this.isOnline) {
|
||||
indicator.classList.add("offline");
|
||||
indicator.title = "Offline";
|
||||
} else if (this.isSyncing) {
|
||||
indicator.classList.add("syncing");
|
||||
indicator.title = "Syncing...";
|
||||
} else {
|
||||
indicator.classList.add("online");
|
||||
indicator.title = "Online";
|
||||
}
|
||||
},
|
||||
|
||||
async updatePendingCount() {
|
||||
if (!this.currentUsername) return;
|
||||
const countElement = document.getElementById("pendingCount");
|
||||
const countValueElement = document.getElementById("pendingCountValue");
|
||||
if (!countElement || !countValueElement) return;
|
||||
const count = await Storage.getPhotoCount(this.currentUsername, "pending");
|
||||
if (count > 0) {
|
||||
countValueElement.textContent = count;
|
||||
countElement.style.display = "block";
|
||||
} else {
|
||||
countElement.style.display = "none";
|
||||
}
|
||||
},
|
||||
|
||||
},
|
||||
getState() {
|
||||
return {
|
||||
isOnline: this.isOnline,
|
||||
isSyncing: this.isSyncing,
|
||||
};
|
||||
}
|
||||
|
||||
// Make Sync globally available as SyncEngine
|
||||
window.SyncEngine = Sync;
|
||||
console.log("[SYNC] SyncEngine exported:", typeof window.SyncEngine);
|
||||
6
app/static/js/sync_fix.txt
Normal file
6
app/static/js/sync_fix.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
async updateRecentPhotos() {
|
||||
// Delegate to Camera module if available
|
||||
if (typeof Camera !== 'undefined' && Camera.updateRecentPhotos) {
|
||||
await Camera.updateRecentPhotos();
|
||||
}
|
||||
},
|
||||
Reference in New Issue
Block a user