// 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 = '';
},
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();
}