1157 lines
44 KiB
JavaScript
1157 lines
44 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
||
// --- State Variables ---
|
||
let currentPage = 0;
|
||
let isLoading = false;
|
||
let hasMore = true;
|
||
let currentSort = 'date_desc';
|
||
let currentSearch = '';
|
||
let currentFolder = 'default';
|
||
let showFavoritesOnly = false;
|
||
let availableFolders = [];
|
||
let currentImages = [];
|
||
let currentImageIndex = -1;
|
||
let selectedForCompare = [];
|
||
let isCompareMode = false;
|
||
let selectedImages = [];
|
||
let contextMenu = null;
|
||
|
||
// --- DOM Elements ---
|
||
const gallery = document.getElementById('gallery');
|
||
const loadingEl = document.getElementById('loading');
|
||
const searchInput = document.getElementById('searchInput');
|
||
const sortSelect = document.getElementById('sortSelect');
|
||
const folderBtn = document.getElementById('folderBtn');
|
||
const folderDropdown = document.getElementById('folderDropdown');
|
||
const folderList = document.getElementById('folderList');
|
||
const addFolderBtn = document.getElementById('addFolderBtn');
|
||
const favoritesFilterBtn = document.getElementById('favoritesFilterBtn');
|
||
const refreshBtn = document.getElementById('refreshBtn');
|
||
const compareModeBtn = document.getElementById('compareModeBtn');
|
||
const compareBtn = document.getElementById('compareBtn');
|
||
const multiSelectActions = document.getElementById('multiSelectActions');
|
||
const themeSelect = document.getElementById('themeSelect');
|
||
|
||
// --- Context Menu Functions ---
|
||
function createContextMenu() {
|
||
const menu = document.createElement('div');
|
||
menu.id = 'contextMenu';
|
||
menu.className = 'context-menu';
|
||
document.body.appendChild(menu);
|
||
return menu;
|
||
}
|
||
|
||
function showContextMenu(e, imageData, card) {
|
||
e.preventDefault();
|
||
|
||
if (!contextMenu) {
|
||
contextMenu = createContextMenu();
|
||
}
|
||
|
||
const isMultiSelect = selectedImages.length > 1;
|
||
const isFavorite = imageData.is_favorite;
|
||
|
||
if (isMultiSelect) {
|
||
contextMenu.innerHTML = `
|
||
<div class="context-menu-item" data-action="delete-selected">
|
||
<span class="ui-icon">■</span> Delete Selected (${selectedImages.length})
|
||
</div>
|
||
<div class="context-menu-item" data-action="favorite-selected">
|
||
<span class="ui-icon">★</span> Add to Favorites
|
||
</div>
|
||
<div class="context-menu-item" data-action="unfavorite-selected">
|
||
<span class="ui-icon">☆</span> Remove from Favorites
|
||
</div>
|
||
<div class="context-menu-divider"></div>
|
||
<div class="context-menu-item" data-action="clear-selection">
|
||
<span class="ui-icon">✕</span> Clear Selection
|
||
</div>
|
||
`;
|
||
} else {
|
||
const imagePath = `/gemini-image-browser/thumbnail?filename=${encodeURIComponent(imageData.path)}&folder=${imageData.folder_id}`;
|
||
contextMenu.innerHTML = `
|
||
<div class="context-menu-item" data-action="open">
|
||
<span class="ui-icon">◆</span> Open
|
||
</div>
|
||
<div class="context-menu-item" data-action="copy-image">
|
||
<span class="ui-icon">▣</span> Copy Image
|
||
</div>
|
||
<div class="context-menu-item" data-action="copy-path">
|
||
<span class="ui-icon">▦</span> Copy Path
|
||
</div>
|
||
<div class="context-menu-divider"></div>
|
||
<div class="context-menu-item" data-action="toggle-favorite">
|
||
<span class="ui-icon">${isFavorite ? '☆' : '★'}</span> ${isFavorite ? 'Remove from' : 'Add to'} Favorites
|
||
</div>
|
||
<div class="context-menu-divider"></div>
|
||
<div class="context-menu-item context-menu-item-danger" data-action="delete">
|
||
<span class="ui-icon">■</span> Delete
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const offsetX = 12;
|
||
const offsetY = 12;
|
||
contextMenu.style.display = 'block';
|
||
contextMenu.style.left = (e.pageX + offsetX) + 'px';
|
||
contextMenu.style.top = (e.pageY + offsetY) + 'px';
|
||
|
||
setTimeout(() => {
|
||
const rect = contextMenu.getBoundingClientRect();
|
||
const viewportWidth = window.innerWidth;
|
||
const viewportHeight = window.innerHeight;
|
||
|
||
if (rect.right > viewportWidth) {
|
||
contextMenu.style.left = (e.pageX - rect.width - offsetX) + 'px';
|
||
}
|
||
if (rect.bottom > viewportHeight) {
|
||
contextMenu.style.top = (e.pageY - rect.height - offsetY) + 'px';
|
||
}
|
||
}, 0);
|
||
|
||
const oldMenu = contextMenu.cloneNode(true);
|
||
contextMenu.parentNode.replaceChild(oldMenu, contextMenu);
|
||
contextMenu = oldMenu;
|
||
|
||
contextMenu.querySelectorAll('.context-menu-item').forEach(item => {
|
||
item.addEventListener('click', async () => {
|
||
const action = item.getAttribute('data-action');
|
||
await handleContextMenuAction(action, imageData, card);
|
||
hideContextMenu();
|
||
});
|
||
});
|
||
|
||
setTimeout(() => {
|
||
document.addEventListener('click', hideContextMenu, { once: true });
|
||
}, 0);
|
||
}
|
||
|
||
function hideContextMenu() {
|
||
if (contextMenu) {
|
||
contextMenu.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function handleContextMenuAction(action, imageData, card) {
|
||
const imagePath = `/gemini-image-browser/thumbnail?filename=${encodeURIComponent(imageData.path)}&folder=${imageData.folder_id}`;
|
||
|
||
switch (action) {
|
||
case 'open':
|
||
const index = parseInt(card.dataset.index);
|
||
openModal(index);
|
||
break;
|
||
|
||
case 'copy-image':
|
||
await copyImageToClipboard(imagePath);
|
||
break;
|
||
|
||
case 'copy-path':
|
||
await copyPathToClipboard(imageData);
|
||
break;
|
||
|
||
case 'toggle-favorite':
|
||
const favBtn = card.querySelector('.favorite-btn');
|
||
await toggleFavorite(imageData.path, imageData.folder_id, card, favBtn);
|
||
break;
|
||
|
||
case 'delete':
|
||
await deleteImage(imageData, card);
|
||
break;
|
||
|
||
case 'delete-selected':
|
||
await deleteSelectedImages();
|
||
break;
|
||
|
||
case 'favorite-selected':
|
||
await favoriteSelectedImages(true);
|
||
break;
|
||
|
||
case 'unfavorite-selected':
|
||
await favoriteSelectedImages(false);
|
||
break;
|
||
|
||
case 'clear-selection':
|
||
clearSelection();
|
||
break;
|
||
}
|
||
}
|
||
|
||
async function copyImageToClipboard(imageUrl) {
|
||
try {
|
||
const response = await fetch(imageUrl);
|
||
const blob = await response.blob();
|
||
await navigator.clipboard.write([
|
||
new ClipboardItem({ [blob.type]: blob })
|
||
]);
|
||
showNotification('Image copied to clipboard');
|
||
} catch (error) {
|
||
console.error('Error copying image:', error);
|
||
showNotification('Failed to copy image', 'error');
|
||
}
|
||
}
|
||
|
||
async function copyPathToClipboard(imageData) {
|
||
try {
|
||
let fullPath;
|
||
|
||
if (imageData.folder_id === 'default') {
|
||
const defaultFolder = availableFolders.find(f => f.id === 'default');
|
||
fullPath = defaultFolder ? `${defaultFolder.path}\\${imageData.path}` : imageData.path;
|
||
} else {
|
||
const folder = availableFolders.find(f => f.id === imageData.folder_id);
|
||
fullPath = folder ? `${folder.path}\\${imageData.path}` : imageData.path;
|
||
}
|
||
|
||
await navigator.clipboard.writeText(fullPath);
|
||
showNotification('Path copied to clipboard');
|
||
} catch (error) {
|
||
console.error('Error copying path:', error);
|
||
showNotification('Failed to copy path', 'error');
|
||
}
|
||
}
|
||
|
||
async function deleteImage(imageData, card) {
|
||
if (!confirm(`Delete ${imageData.name}?`)) return;
|
||
|
||
try {
|
||
const response = await fetch('/gemini-image-browser/delete', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
filename: imageData.path,
|
||
folder_id: imageData.folder_id
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
card.remove();
|
||
showNotification('Image deleted');
|
||
} else {
|
||
throw new Error(data.error || 'Delete failed');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deleting image:', error);
|
||
showNotification('Failed to delete image', 'error');
|
||
}
|
||
}
|
||
|
||
async function deleteSelectedImages() {
|
||
if (!confirm(`Delete ${selectedImages.length} selected images?`)) return;
|
||
|
||
let successCount = 0;
|
||
for (const imageData of selectedImages) {
|
||
try {
|
||
const response = await fetch('/gemini-image-browser/delete', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
filename: imageData.path,
|
||
folder_id: imageData.folder_id
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
const card = document.querySelector(`[data-filename="${imageData.path}"][data-folder-id="${imageData.folder_id}"]`);
|
||
if (card) card.remove();
|
||
successCount++;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deleting image:', error);
|
||
}
|
||
}
|
||
|
||
clearSelection();
|
||
showNotification(`Deleted ${successCount} of ${selectedImages.length} images`);
|
||
}
|
||
|
||
async function favoriteSelectedImages(addToFavorites) {
|
||
for (const imageData of selectedImages) {
|
||
const card = document.querySelector(`[data-filename="${imageData.path}"][data-folder-id="${imageData.folder_id}"]`);
|
||
if (!card) continue;
|
||
|
||
const isFavorite = card.classList.contains('favorited');
|
||
const shouldToggle = (addToFavorites && !isFavorite) || (!addToFavorites && isFavorite);
|
||
|
||
if (shouldToggle) {
|
||
const favBtn = card.querySelector('.favorite-btn');
|
||
await toggleFavorite(imageData.path, imageData.folder_id, card, favBtn);
|
||
}
|
||
}
|
||
|
||
showNotification(`Updated favorites for ${selectedImages.length} images`);
|
||
}
|
||
|
||
function showNotification(message, type = 'success') {
|
||
const notification = document.createElement('div');
|
||
notification.className = `notification notification-${type}`;
|
||
notification.textContent = message;
|
||
document.body.appendChild(notification);
|
||
|
||
setTimeout(() => notification.classList.add('show'), 10);
|
||
setTimeout(() => {
|
||
notification.classList.remove('show');
|
||
setTimeout(() => notification.remove(), 300);
|
||
}, 2000);
|
||
}
|
||
|
||
function toggleImageSelection(imageData, card, event) {
|
||
const index = selectedImages.findIndex(
|
||
img => img.path === imageData.path && img.folder_id === imageData.folder_id
|
||
);
|
||
|
||
if (index !== -1) {
|
||
selectedImages.splice(index, 1);
|
||
card.classList.remove('selected');
|
||
} else {
|
||
selectedImages.push(imageData);
|
||
card.classList.add('selected');
|
||
}
|
||
|
||
updateMultiSelectActions();
|
||
}
|
||
|
||
function clearSelection() {
|
||
selectedImages = [];
|
||
document.querySelectorAll('.image-card.selected').forEach(card => {
|
||
card.classList.remove('selected');
|
||
});
|
||
updateMultiSelectActions();
|
||
}
|
||
|
||
function updateMultiSelectActions() {
|
||
if (selectedImages.length > 0) {
|
||
multiSelectActions.style.display = 'flex';
|
||
document.getElementById('selectedCount').textContent = selectedImages.length;
|
||
} else {
|
||
multiSelectActions.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// CONTINUATION FROM PART 1...
|
||
|
||
async function loadImages(reset = false) {
|
||
if (isLoading || (!hasMore && !reset)) return;
|
||
isLoading = true;
|
||
if (reset) {
|
||
currentPage = 0;
|
||
hasMore = true;
|
||
gallery.innerHTML = '';
|
||
clearSelection();
|
||
}
|
||
loadingEl.style.display = 'block';
|
||
|
||
const params = new URLSearchParams({
|
||
page: currentPage,
|
||
per_page: 50,
|
||
sort: currentSort,
|
||
search: currentSearch,
|
||
folder: showFavoritesOnly ? 'all' : currentFolder,
|
||
favorites: showFavoritesOnly,
|
||
});
|
||
|
||
console.log(`Gemini Browser: Requesting images with params: ${params.toString()}`);
|
||
|
||
try {
|
||
const response = await fetch(`/gemini-image-browser/list?${params.toString()}`);
|
||
const data = await response.json();
|
||
|
||
console.log("Gemini Browser: Received data from server:", data);
|
||
|
||
if (data.error) { throw new Error(data.error); }
|
||
|
||
hasMore = data.has_more;
|
||
if (reset) {
|
||
availableFolders = data.folders;
|
||
updateFolderList();
|
||
}
|
||
|
||
if (data.images.length === 0 && reset) {
|
||
gallery.innerHTML = '<p>No images found with current filters.</p>';
|
||
}
|
||
|
||
console.log(`Gemini Browser: Rendering ${data.images.length} images.`);
|
||
|
||
data.images.forEach((img, index) => {
|
||
const globalIndex = reset ? index : currentImages.length + index;
|
||
|
||
const card = document.createElement('div');
|
||
card.className = 'image-card' + (img.is_favorite ? ' favorited' : '');
|
||
card.dataset.filename = img.path;
|
||
card.dataset.folderId = img.folder_id;
|
||
card.dataset.index = globalIndex;
|
||
|
||
const favBtn = document.createElement('button');
|
||
favBtn.className = 'favorite-btn' + (img.is_favorite ? ' favorited' : '');
|
||
favBtn.innerHTML = '★';
|
||
favBtn.title = 'Toggle favorite';
|
||
favBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
toggleFavorite(img.path, img.folder_id, card, favBtn);
|
||
};
|
||
card.appendChild(favBtn);
|
||
|
||
const isVideo = /\.(mp4|webm|mov)$/i.test(img.name);
|
||
const isAudio = /\.(mp3|wav|ogg|flac|m4a)$/i.test(img.name);
|
||
let mediaHtml;
|
||
if (isVideo) {
|
||
mediaHtml = `<video src="/gemini-image-browser/thumbnail?filename=${encodeURIComponent(img.path)}&folder=${img.folder_id}" muted loop onmouseover="this.play()" onmouseout="this.pause()"></video>`;
|
||
} else if (isAudio) {
|
||
mediaHtml = `<div class="audio-thumbnail"><div class="audio-icon">♪</div><audio src="/gemini-image-browser/thumbnail?filename=${encodeURIComponent(img.path)}&folder=${img.folder_id}" preload="metadata"></audio></div>`;
|
||
} else {
|
||
mediaHtml = `<img src="/gemini-image-browser/thumbnail?filename=${encodeURIComponent(img.path)}&folder=${img.folder_id}" alt="${img.name}" loading="lazy">`;
|
||
}
|
||
|
||
const mediaEl = document.createElement('div');
|
||
mediaEl.innerHTML = mediaHtml;
|
||
mediaEl.onclick = (e) => {
|
||
if (e.ctrlKey || e.metaKey) {
|
||
toggleImageSelection(img, card, e);
|
||
} else if (isCompareMode) {
|
||
toggleCompareSelection(globalIndex, card);
|
||
} else {
|
||
openModal(globalIndex);
|
||
}
|
||
};
|
||
|
||
mediaEl.addEventListener('contextmenu', (e) => {
|
||
showContextMenu(e, img, card);
|
||
});
|
||
|
||
card.appendChild(mediaEl);
|
||
|
||
if (isVideo) {
|
||
const videoIndicator = document.createElement('div');
|
||
videoIndicator.className = 'video-indicator';
|
||
videoIndicator.textContent = 'VIDEO';
|
||
card.appendChild(videoIndicator);
|
||
} else if (isAudio) {
|
||
const audioIndicator = document.createElement('div');
|
||
audioIndicator.className = 'audio-indicator';
|
||
audioIndicator.textContent = 'AUDIO';
|
||
card.appendChild(audioIndicator);
|
||
}
|
||
|
||
const infoEl = document.createElement('div');
|
||
infoEl.className = 'image-info';
|
||
infoEl.innerHTML = `<div class="image-name" title="${img.name}">${img.name}</div>`;
|
||
card.appendChild(infoEl);
|
||
|
||
gallery.appendChild(card);
|
||
});
|
||
|
||
if (reset) {
|
||
currentImages = data.images;
|
||
} else {
|
||
currentImages = [...currentImages, ...data.images];
|
||
}
|
||
currentPage++;
|
||
} catch (error) {
|
||
console.error('Gemini Browser: Error loading images:', error);
|
||
gallery.innerHTML = `<p>Error loading images. Check browser console (F12) for details.</p>`;
|
||
} finally {
|
||
isLoading = false;
|
||
loadingEl.style.display = hasMore ? 'block' : 'none';
|
||
}
|
||
}
|
||
|
||
async function toggleFavorite(filename, folderId, card, btn) {
|
||
try {
|
||
const response = await fetch('/gemini-image-browser/favorite', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ filename, folder_id: folderId })
|
||
});
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
const isFavorite = data.is_favorite;
|
||
card.classList.toggle('favorited', isFavorite);
|
||
btn.classList.toggle('favorited', isFavorite);
|
||
if (showFavoritesOnly && !isFavorite) {
|
||
card.style.display = 'none';
|
||
}
|
||
}
|
||
} catch (error) { console.error('Error toggling favorite:', error); }
|
||
}
|
||
|
||
function toggleCompareSelection(index, card) {
|
||
const imageData = currentImages[index];
|
||
const compareIndex = selectedForCompare.findIndex(
|
||
img => img.path === imageData.path && img.folder_id === imageData.folder_id
|
||
);
|
||
|
||
if (compareIndex !== -1) {
|
||
selectedForCompare.splice(compareIndex, 1);
|
||
card.classList.remove('selected-for-compare', 'compare-first', 'compare-second');
|
||
|
||
if (selectedForCompare.length === 1) {
|
||
const remainingCard = document.querySelector(`[data-filename="${selectedForCompare[0].path}"][data-folder-id="${selectedForCompare[0].folder_id}"]`);
|
||
if (remainingCard) {
|
||
remainingCard.classList.remove('compare-second');
|
||
remainingCard.classList.add('compare-first');
|
||
}
|
||
}
|
||
} else if (selectedForCompare.length < 2) {
|
||
selectedForCompare.push(imageData);
|
||
card.classList.add('selected-for-compare');
|
||
|
||
if (selectedForCompare.length === 1) {
|
||
card.classList.add('compare-first');
|
||
} else if (selectedForCompare.length === 2) {
|
||
card.classList.add('compare-second');
|
||
}
|
||
} else {
|
||
alert('You can only compare 2 images at a time. Deselect one first.');
|
||
}
|
||
|
||
compareBtn.style.display = selectedForCompare.length === 2 ? 'block' : 'none';
|
||
}
|
||
|
||
function openCompareModal() {
|
||
if (selectedForCompare.length !== 2) return;
|
||
|
||
const modal = document.getElementById('compareModal');
|
||
const container = document.getElementById('compareContainer');
|
||
const img1 = selectedForCompare[0];
|
||
const img2 = selectedForCompare[1];
|
||
|
||
const url1 = `/gemini-image-browser/thumbnail?filename=${encodeURIComponent(img1.path)}&folder=${img1.folder_id}`;
|
||
const url2 = `/gemini-image-browser/thumbnail?filename=${encodeURIComponent(img2.path)}&folder=${img2.folder_id}`;
|
||
|
||
container.innerHTML = `
|
||
<div class="compare-wrapper">
|
||
<div class="compare-labels">
|
||
<div class="compare-label compare-label-left">${escapeHtml(img1.name)}</div>
|
||
<div class="compare-label compare-label-right">${escapeHtml(img2.name)}</div>
|
||
</div>
|
||
<div class="compare-image-container">
|
||
<img src="${url2}" alt="Image 2" class="compare-img compare-img-back">
|
||
<div class="compare-img-front-wrapper">
|
||
<img src="${url1}" alt="Image 1" class="compare-img compare-img-front">
|
||
</div>
|
||
<div class="compare-slider" id="compareSlider">
|
||
<div class="compare-slider-handle"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
modal.classList.add('active');
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
initCompareSlider();
|
||
}
|
||
|
||
function initCompareSlider() {
|
||
const slider = document.getElementById('compareSlider');
|
||
const frontWrapper = document.querySelector('.compare-img-front-wrapper');
|
||
const container = document.querySelector('.compare-image-container');
|
||
let isDragging = false;
|
||
|
||
function updateSlider(clientX) {
|
||
const rect = container.getBoundingClientRect();
|
||
let x = clientX - rect.left;
|
||
x = Math.max(0, Math.min(x, rect.width));
|
||
const percentage = (x / rect.width) * 100;
|
||
|
||
slider.style.left = percentage + '%';
|
||
frontWrapper.style.width = percentage + '%';
|
||
}
|
||
|
||
slider.addEventListener('mousedown', (e) => {
|
||
isDragging = true;
|
||
e.preventDefault();
|
||
});
|
||
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (!isDragging) return;
|
||
updateSlider(e.clientX);
|
||
});
|
||
|
||
document.addEventListener('mouseup', () => {
|
||
isDragging = false;
|
||
});
|
||
|
||
container.addEventListener('click', (e) => {
|
||
if (!isDragging) {
|
||
updateSlider(e.clientX);
|
||
}
|
||
});
|
||
|
||
slider.addEventListener('touchstart', (e) => {
|
||
isDragging = true;
|
||
e.preventDefault();
|
||
});
|
||
|
||
document.addEventListener('touchmove', (e) => {
|
||
if (!isDragging) return;
|
||
updateSlider(e.touches[0].clientX);
|
||
});
|
||
|
||
document.addEventListener('touchend', () => {
|
||
isDragging = false;
|
||
});
|
||
}
|
||
|
||
window.closeCompareModal = () => {
|
||
const modal = document.getElementById('compareModal');
|
||
modal.classList.remove('active');
|
||
document.body.style.overflow = '';
|
||
document.getElementById('compareContainer').innerHTML = '';
|
||
};
|
||
|
||
function syncGalleryScroll(index) {
|
||
const card = document.querySelector(`.image-card[data-index="${index}"]`);
|
||
if (!card) return;
|
||
|
||
card.scrollIntoView({
|
||
behavior: "instant",
|
||
block: "center",
|
||
inline: "nearest"
|
||
});
|
||
}
|
||
|
||
async function openModal(index) {
|
||
currentImageIndex = index;
|
||
const imageData = currentImages[index];
|
||
const imagePath = imageData.path;
|
||
const folderId = imageData.folder_id;
|
||
|
||
const modal = document.getElementById('modal');
|
||
const mediaContainer = document.getElementById('modalMediaContainer');
|
||
const metadataPanel = document.getElementById('metadataPanel');
|
||
const navPrev = document.getElementById('modalNavPrev');
|
||
const navNext = document.getElementById('modalNavNext');
|
||
|
||
navPrev.style.display = index > 0 ? 'block' : 'none';
|
||
navNext.style.display = (index < currentImages.length - 1 || hasMore) ? 'block' : 'none';
|
||
|
||
const isVideo = /\.(mp4|webm|mov)$/i.test(imagePath);
|
||
const isAudio = /\.(mp3|wav|ogg|flac|m4a)$/i.test(imagePath);
|
||
const url = `/gemini-image-browser/thumbnail?filename=${encodeURIComponent(imagePath)}&folder=${folderId}`;
|
||
|
||
if (isVideo) {
|
||
mediaContainer.innerHTML = `<video src="${url}" controls autoplay loop></video>`;
|
||
} else if (isAudio) {
|
||
mediaContainer.innerHTML = `<div class="audio-modal-container"><div class="audio-modal-icon">♪</div><audio src="${url}" controls autoplay></audio></div>`;
|
||
} else {
|
||
mediaContainer.innerHTML = `<img src="${url}" alt="">`;
|
||
}
|
||
|
||
modal.classList.add('active');
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
syncGalleryScroll(index);
|
||
|
||
const isFavorite = imageData.is_favorite;
|
||
metadataPanel.innerHTML = `
|
||
<div class="modal-header-actions">
|
||
<button class="modal-action-btn ${isFavorite ? 'favorite' : ''}" onclick="toggleModalFavorite()">
|
||
<span class="ui-icon">${isFavorite ? '★' : '☆'}</span>
|
||
${isFavorite ? 'Favorited' : 'Add to Favorites'}
|
||
</button>
|
||
</div>
|
||
<div class="metadata-section">
|
||
<div class="metadata-section-header">
|
||
<span class="icon ui-icon">■</span> Prompts
|
||
</div>
|
||
<div class="metadata-section-content" id="promptsSection">
|
||
<p style="color: #888; margin: 0;">Loading...</p>
|
||
</div>
|
||
</div>
|
||
<div class="metadata-section">
|
||
<div class="metadata-section-header">
|
||
<span class="icon ui-icon">▦</span> Generation Settings
|
||
</div>
|
||
<div class="metadata-section-content" id="settingsSection">
|
||
<p style="color: #888; margin: 0;">Loading...</p>
|
||
</div>
|
||
</div>
|
||
<div class="metadata-section">
|
||
<div class="metadata-section-header">
|
||
<span class="icon ui-icon">▣</span> LoRAs
|
||
</div>
|
||
<div class="metadata-section-content" id="lorasSection">
|
||
<p style="color: #888; margin: 0;">Loading...</p>
|
||
</div>
|
||
</div>
|
||
<div class="metadata-section">
|
||
<div class="metadata-section-header">
|
||
<span class="icon ui-icon">▢</span> File Info
|
||
</div>
|
||
<div class="metadata-section-content" id="fileInfoSection">
|
||
<div class="file-info-grid">
|
||
<div class="file-info-item full-width">
|
||
<div class="label">Filename</div>
|
||
<div class="value">${escapeHtml(imageData.name)}</div>
|
||
</div>
|
||
<div class="file-info-item">
|
||
<div class="label">Size</div>
|
||
<div class="value">${formatFileSize(imageData.size)}</div>
|
||
</div>
|
||
<div class="file-info-item">
|
||
<div class="label">Modified</div>
|
||
<div class="value">${formatDate(imageData.modified)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="metadata-section">
|
||
<div class="metadata-section-header">
|
||
<span class="icon ui-icon">▧</span> Other Metadata
|
||
</div>
|
||
<div class="metadata-section-content" id="otherMetaSection">
|
||
<p style="color: #888; margin: 0;">Loading...</p>
|
||
</div>
|
||
</div>
|
||
<button class="modal-delete-btn" onclick="deleteCurrentImage()">Delete Image</button>
|
||
`;
|
||
|
||
try {
|
||
const metaUrl = `/gemini-image-browser/metadata?filename=${encodeURIComponent(imagePath)}&folder=${folderId}`;
|
||
const response = await fetch(metaUrl);
|
||
const data = await response.json();
|
||
displayMetadata(data);
|
||
} catch (error) {
|
||
document.getElementById('promptsSection').innerHTML =
|
||
`<p style="color: #dc3545;">Error loading metadata: ${error.message}</p>`;
|
||
}
|
||
}
|
||
|
||
async function navigateModal(direction) {
|
||
const newIndex = currentImageIndex + direction;
|
||
|
||
if (direction === 1 && newIndex >= currentImages.length) {
|
||
if (hasMore && !isLoading) {
|
||
const navNext = document.getElementById('modalNavNext');
|
||
if (navNext) navNext.style.opacity = '0.5';
|
||
|
||
await loadImages(false);
|
||
|
||
if (navNext) navNext.style.opacity = '1';
|
||
|
||
if (newIndex < currentImages.length) {
|
||
openModal(newIndex);
|
||
}
|
||
}
|
||
} else if (newIndex >= 0 && newIndex < currentImages.length) {
|
||
openModal(newIndex);
|
||
}
|
||
}
|
||
|
||
window.navigatePrev = () => navigateModal(-1);
|
||
window.navigateNext = () => navigateModal(1);
|
||
|
||
window.closeModal = () => {
|
||
const modal = document.getElementById('modal');
|
||
modal.classList.remove('active');
|
||
document.body.style.overflow = '';
|
||
document.getElementById('modalMediaContainer').innerHTML = '';
|
||
};
|
||
|
||
document.getElementById('modal').addEventListener('click', (e) => {
|
||
if (e.target.id === 'modal' || e.target.id === 'modalMediaContainer') {
|
||
closeModal();
|
||
}
|
||
});
|
||
|
||
function displayMetadata(data) {
|
||
const { parsed, dimensions } = data;
|
||
|
||
if (!parsed) {
|
||
document.getElementById('promptsSection').innerHTML = '<p style="color: #888;">Could not parse metadata.</p>';
|
||
return;
|
||
}
|
||
|
||
let promptsHtml = '';
|
||
if (parsed.prompt || parsed.negative_prompt) {
|
||
if (parsed.prompt) {
|
||
promptsHtml += `
|
||
<div class="metadata-item">
|
||
<div class="metadata-label">Positive Prompt</div>
|
||
<div class="metadata-prompt">${escapeHtml(parsed.prompt)}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
if (parsed.negative_prompt) {
|
||
promptsHtml += `
|
||
<div class="metadata-item">
|
||
<div class="metadata-label">Negative Prompt</div>
|
||
<div class="metadata-prompt">${escapeHtml(parsed.negative_prompt)}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
} else {
|
||
promptsHtml = '<p style="color: #888; margin: 0;">No prompts found.</p>';
|
||
}
|
||
document.getElementById('promptsSection').innerHTML = promptsHtml;
|
||
|
||
let settingsHtml = '<div class="settings-grid">';
|
||
|
||
const size = dimensions ? `${dimensions.width} × ${dimensions.height}` : (parsed.width && parsed.height ? `${parsed.width} × ${parsed.height}` : null);
|
||
|
||
const settings = [
|
||
{ label: 'Model', value: parsed.model, fullWidth: true },
|
||
{ label: 'Seed', value: parsed.seed },
|
||
{ label: 'Steps', value: parsed.steps },
|
||
{ label: 'CFG Scale', value: parsed.cfg },
|
||
{ label: 'Sampler', value: parsed.sampler },
|
||
{ label: 'Scheduler', value: parsed.scheduler },
|
||
{ label: 'Size', value: size }
|
||
];
|
||
|
||
settings.forEach(setting => {
|
||
if (setting.value) {
|
||
settingsHtml += `
|
||
<div class="metadata-item ${setting.fullWidth ? 'full-width' : ''}">
|
||
<div class="metadata-label">${setting.label}</div>
|
||
<div class="metadata-value">${escapeHtml(setting.value)}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
settingsHtml += '</div>';
|
||
document.getElementById('settingsSection').innerHTML = settingsHtml;
|
||
|
||
let lorasHtml = '';
|
||
if (parsed.loras && parsed.loras.length > 0) {
|
||
parsed.loras.forEach(lora => {
|
||
lorasHtml += `
|
||
<div class="lora-item">
|
||
<div class="lora-name">${escapeHtml(lora.name)}</div>
|
||
<div class="lora-strength">Model: ${lora.strength_model} | CLIP: ${lora.strength_clip}</div>
|
||
</div>
|
||
`;
|
||
});
|
||
} else {
|
||
lorasHtml = '<p style="color: #888; margin: 0;">No LoRAs used.</p>';
|
||
}
|
||
document.getElementById('lorasSection').innerHTML = lorasHtml;
|
||
|
||
const extra = data.extra || {};
|
||
const raw = data.raw || {};
|
||
const seenKeys = new Set();
|
||
const entries = [];
|
||
|
||
Object.keys(extra).forEach(key => {
|
||
entries.push([key, extra[key]]);
|
||
seenKeys.add(key);
|
||
});
|
||
|
||
Object.keys(raw).forEach(key => {
|
||
if (!seenKeys.has(key)) {
|
||
entries.push([key, raw[key]]);
|
||
}
|
||
});
|
||
|
||
entries.sort((a, b) => a[0].localeCompare(b[0]));
|
||
|
||
let otherHtml = '';
|
||
if (entries.length > 0) {
|
||
entries.forEach(([key, value]) => {
|
||
const safeKey = escapeHtml(key);
|
||
const safeVal = escapeHtml(String(value ?? ''));
|
||
otherHtml += `
|
||
<div class="metadata-kv">
|
||
<div class="label">${safeKey}</div>
|
||
<div class="value">${safeVal}</div>
|
||
</div>
|
||
`;
|
||
});
|
||
} else {
|
||
otherHtml = '<p style="color: #888; margin: 0;">No additional metadata found.</p>';
|
||
}
|
||
const otherEl = document.getElementById('otherMetaSection');
|
||
if (otherEl) otherEl.innerHTML = otherHtml;
|
||
}
|
||
|
||
window.toggleSection = (header) => {
|
||
return;
|
||
};
|
||
|
||
window.toggleModalFavorite = async () => {
|
||
const imageData = currentImages[currentImageIndex];
|
||
const card = document.querySelector(`[data-filename="${imageData.path}"][data-folder-id="${imageData.folder_id}"]`);
|
||
const favBtn = card?.querySelector('.favorite-btn');
|
||
|
||
await toggleFavorite(imageData.path, imageData.folder_id, card, favBtn);
|
||
|
||
const modalBtn = document.querySelector('.modal-action-btn.favorite, .modal-action-btn:not(.favorite)');
|
||
const isFavorite = card?.classList.contains('favorited');
|
||
if (modalBtn) {
|
||
modalBtn.className = `modal-action-btn ${isFavorite ? 'favorite' : ''}`;
|
||
modalBtn.innerHTML = `<span class="ui-icon">${isFavorite ? '★' : '☆'}</span> ${isFavorite ? 'Favorited' : 'Add to Favorites'}`;
|
||
}
|
||
|
||
currentImages[currentImageIndex].is_favorite = isFavorite;
|
||
};
|
||
|
||
window.deleteCurrentImage = async () => {
|
||
const imageData = currentImages[currentImageIndex];
|
||
const card = document.querySelector(`[data-filename="${imageData.path}"][data-folder-id="${imageData.folder_id}"]`);
|
||
|
||
if (!confirm(`Delete ${imageData.name}?`)) return;
|
||
|
||
try {
|
||
const response = await fetch('/gemini-image-browser/delete', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
filename: imageData.path,
|
||
folder_id: imageData.folder_id
|
||
})
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
if (card) card.remove();
|
||
closeModal();
|
||
showNotification('Image deleted');
|
||
|
||
currentImages.splice(currentImageIndex, 1);
|
||
} else {
|
||
throw new Error(data.error || 'Delete failed');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deleting image:', error);
|
||
showNotification('Failed to delete image', 'error');
|
||
}
|
||
};
|
||
|
||
function formatFileSize(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 parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||
}
|
||
|
||
function formatDate(timestamp) {
|
||
const date = new Date(timestamp * 1000);
|
||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||
}
|
||
|
||
function updateFolderList() {
|
||
folderList.innerHTML = '';
|
||
availableFolders.forEach(folder => {
|
||
const item = document.createElement('div');
|
||
item.className = 'folder-item' + (folder.id === currentFolder ? ' active' : '');
|
||
|
||
const content = document.createElement('div');
|
||
content.className = 'folder-item-content';
|
||
content.textContent = folder.name;
|
||
content.onclick = () => {
|
||
currentFolder = folder.id;
|
||
folderDropdown.classList.remove('active');
|
||
loadImages(true);
|
||
};
|
||
item.appendChild(content);
|
||
|
||
// Add delete button only for custom folders (not the default)
|
||
if (folder.id !== 'default') {
|
||
const deleteBtn = document.createElement('button');
|
||
deleteBtn.className = 'folder-item-delete';
|
||
deleteBtn.innerHTML = '■';
|
||
deleteBtn.title = 'Delete folder';
|
||
deleteBtn.onclick = async (e) => {
|
||
e.stopPropagation();
|
||
if (confirm(`Remove "${folder.name}" from the list? (Files won't be deleted)`)) {
|
||
await removeFolder(folder.id);
|
||
}
|
||
};
|
||
item.appendChild(deleteBtn);
|
||
}
|
||
|
||
folderList.appendChild(item);
|
||
});
|
||
}
|
||
|
||
async function addNewFolder() {
|
||
const nameInput = document.getElementById('folderName');
|
||
const pathInput = document.getElementById('folderPath');
|
||
if (!nameInput.value || !pathInput.value) {
|
||
alert('Please provide a name and path.');
|
||
return;
|
||
}
|
||
try {
|
||
const response = await fetch('/gemini-image-browser/add_folder', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: nameInput.value, path: pathInput.value })
|
||
});
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
nameInput.value = '';
|
||
pathInput.value = '';
|
||
folderDropdown.classList.remove('active');
|
||
loadImages(true);
|
||
} else {
|
||
throw new Error(data.error);
|
||
}
|
||
} catch (error) {
|
||
alert(`Error adding folder: ${error.message}`);
|
||
}
|
||
}
|
||
async function removeFolder(folderId) {
|
||
try {
|
||
const response = await fetch('/gemini-image-browser/remove_folder', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ folder_id: folderId })
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
// If we're currently viewing the deleted folder, switch to default
|
||
if (currentFolder === folderId) {
|
||
currentFolder = 'default';
|
||
loadImages(true);
|
||
} else {
|
||
// Just reload the folder list
|
||
const listResponse = await fetch('/gemini-image-browser/list?page=0&per_page=1');
|
||
const listData = await listResponse.json();
|
||
availableFolders = listData.folders;
|
||
updateFolderList();
|
||
}
|
||
showNotification('Folder removed from list');
|
||
} else {
|
||
throw new Error(data.error || 'Remove failed');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error removing folder:', error);
|
||
showNotification('Failed to remove folder', 'error');
|
||
}
|
||
}
|
||
|
||
function debounce(func, delay) {
|
||
let timeout;
|
||
return function (...args) {
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(() => func.apply(this, args), delay);
|
||
};
|
||
}
|
||
|
||
function applyTheme(theme) {
|
||
const normalized = theme === 'nier' ? 'nier' : 'dark';
|
||
document.body.dataset.theme = normalized;
|
||
}
|
||
|
||
searchInput.addEventListener('input', debounce((e) => {
|
||
currentSearch = e.target.value;
|
||
loadImages(true);
|
||
}, 300));
|
||
|
||
sortSelect.addEventListener('change', (e) => {
|
||
currentSort = e.target.value;
|
||
loadImages(true);
|
||
});
|
||
|
||
favoritesFilterBtn.addEventListener('click', () => {
|
||
showFavoritesOnly = !showFavoritesOnly;
|
||
favoritesFilterBtn.classList.toggle('active', showFavoritesOnly);
|
||
loadImages(true);
|
||
});
|
||
|
||
refreshBtn.addEventListener('click', () => loadImages(true));
|
||
addFolderBtn.addEventListener('click', addNewFolder);
|
||
|
||
if (themeSelect) {
|
||
const savedTheme = localStorage.getItem('geminiImageBrowserTheme') || 'dark';
|
||
themeSelect.value = savedTheme;
|
||
applyTheme(savedTheme);
|
||
|
||
themeSelect.addEventListener('change', (e) => {
|
||
const selected = e.target.value;
|
||
applyTheme(selected);
|
||
localStorage.setItem('geminiImageBrowserTheme', selected);
|
||
});
|
||
}
|
||
|
||
compareModeBtn.addEventListener('click', () => {
|
||
isCompareMode = !isCompareMode;
|
||
compareModeBtn.classList.toggle('active', isCompareMode);
|
||
|
||
if (!isCompareMode) {
|
||
selectedForCompare = [];
|
||
compareBtn.style.display = 'none';
|
||
document.querySelectorAll('.selected-for-compare').forEach(card => {
|
||
card.classList.remove('selected-for-compare', 'compare-first', 'compare-second');
|
||
});
|
||
}
|
||
});
|
||
|
||
compareBtn.addEventListener('click', openCompareModal);
|
||
|
||
document.getElementById('deleteSelectedBtn').addEventListener('click', deleteSelectedImages);
|
||
document.getElementById('favoriteSelectedBtn').addEventListener('click', () => favoriteSelectedImages(true));
|
||
document.getElementById('unfavoriteSelectedBtn').addEventListener('click', () => favoriteSelectedImages(false));
|
||
document.getElementById('clearSelectionBtn').addEventListener('click', clearSelection);
|
||
|
||
folderBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
folderDropdown.classList.toggle('active');
|
||
});
|
||
|
||
document.addEventListener('click', (e) => {
|
||
if (!folderDropdown.contains(e.target) && !folderBtn.contains(e.target)) {
|
||
folderDropdown.classList.remove('active');
|
||
}
|
||
hideContextMenu();
|
||
});
|
||
|
||
// OPTIMIZED SCROLL HANDLING - THE FIX FOR SMOOTH SCROLLING
|
||
let ticking = false;
|
||
|
||
function checkScrollPosition() {
|
||
const scrollY = window.scrollY;
|
||
const windowHeight = window.innerHeight;
|
||
const documentHeight = document.documentElement.scrollHeight;
|
||
|
||
if (!isLoading && hasMore && (scrollY + windowHeight >= documentHeight - 500)) {
|
||
loadImages();
|
||
}
|
||
|
||
ticking = false;
|
||
}
|
||
|
||
window.addEventListener('scroll', () => {
|
||
if (!ticking) {
|
||
window.requestAnimationFrame(checkScrollPosition);
|
||
ticking = true;
|
||
}
|
||
}, { passive: true });
|
||
|
||
document.addEventListener('keydown', e => {
|
||
const modal = document.getElementById('modal');
|
||
const isModalOpen = modal && modal.classList.contains('active');
|
||
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
||
return;
|
||
}
|
||
|
||
if (e.key === 'Escape') {
|
||
if (isModalOpen) {
|
||
closeModal();
|
||
}
|
||
hideContextMenu();
|
||
if (selectedImages.length > 0) {
|
||
clearSelection();
|
||
}
|
||
} else if (isModalOpen && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
||
e.preventDefault();
|
||
if (e.key === 'ArrowLeft') {
|
||
navigatePrev();
|
||
} else if (e.key === 'ArrowRight') {
|
||
navigateNext();
|
||
}
|
||
}
|
||
});
|
||
|
||
function escapeHtml(text) {
|
||
if (text == null) return '';
|
||
return String(text).replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[m]);
|
||
}
|
||
|
||
loadImages(true);
|
||
});
|