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>
531 lines
18 KiB
JavaScript
531 lines
18 KiB
JavaScript
// 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();
|
||
}
|