Files
nextsnap/app/static/js/filebrowser.js
kamaji 0eef9bf2f3 Fix gallery rename applying to wrong files
After renaming a file, loadDirectory() was called which rebuilt the
galleryImages array in new alphabetical order. The gallery index
didn't change, so it now pointed to a different file — subsequent
renames hit the wrong file.

Fix: only update local state during the gallery session, defer the
file list refresh to when the gallery closes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:27:26 -06:00

531 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = '';
// 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();
}