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:
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();
|
||||
}
|
||||
Reference in New Issue
Block a user