// 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 = '

Loading...

'; 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 = `

Failed to load directory: ${error.message}

`; } }, renderBreadcrumb(path) { const breadcrumb = document.getElementById('breadcrumb'); const parts = path.split('/').filter(p => p); let html = 'Home'; let currentPath = ''; parts.forEach((part, index) => { currentPath += '/' + part; html += ' / '; if (index === parts.length - 1) { html += `${part}`; } else { html += `${part}`; } }); 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 = '

This folder is empty

'; 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 = '
'; 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 += `
${icon}
${this.escapeHtml(item.name)}
${sizeText ? `
${sizeText}
` : ''}
${item.type === 'directory' ? '' : ''}
`; }); html += '
'; 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 = ''; // Refresh file list to reflect any renames this.loadDirectory(this.currentPath); }, 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'); } 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(); }