Files
2026-10-13 22:27:11 -06:00

1796 lines
77 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.
document.addEventListener('DOMContentLoaded', () => {
// --- Mode Detection ---
const isMiniMode = new URLSearchParams(window.location.search).get('mode') === 'mini';
// --- 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 isMultiSelectMode = false;
let selectedImages = [];
let contextMenu = null;
let modalZoom = 1;
let modalPanX = 0;
let modalPanY = 0;
let isModalPanning = false;
let modalPanStartX = 0;
let modalPanStartY = 0;
let modalPanOriginX = 0;
let modalPanOriginY = 0;
let suppressNextModalStageClose = false;
let modalPanListenersBound = false;
let compareZoom = 1;
let comparePanX = 0;
let comparePanY = 0;
let isComparePanning = false;
let compareViewMode = 'wipe';
let comparePosition = 50;
let compareAmount = 50;
let compareViewportWidth = 0;
let compareViewportHeight = 0;
let compareEvents = null;
let compareResizeObserver = null;
const COMPARE_MIN_ZOOM = 1;
const COMPARE_MAX_ZOOM = 8;
const COMPARE_ZOOM_STEP = 0.1;
const MODAL_MIN_ZOOM = 0.25;
const MODAL_MAX_ZOOM = 6;
const MODAL_ZOOM_STEP = 0.15;
// --- 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 multiSelectModeBtn = document.getElementById('multiSelectModeBtn');
const compareBtn = document.getElementById('compareBtn');
const multiSelectActions = document.getElementById('multiSelectActions');
const themeSelect = document.getElementById('themeSelect');
const favoritesToolbarBtn = document.getElementById('favoritesToolbarBtn');
const allImagesBtn = document.getElementById('allImagesBtn');
const studioShell = document.getElementById('studioShell');
const sidebarCollapseBtn = document.getElementById('sidebarCollapseBtn');
const thumbnailSize = document.getElementById('thumbnailSize');
const currentLocation = document.getElementById('currentLocation');
const currentPath = document.getElementById('currentPath');
// --- 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';
}
}
function showFolderContextMenu(event, folder) {
if (folder.id === 'default' || studioShell?.classList.contains('sidebar-collapsed')) return;
event.preventDefault();
event.stopPropagation();
if (!contextMenu) contextMenu = createContextMenu();
contextMenu.innerHTML = `
<div class="context-menu-item context-menu-item-danger" data-action="remove-folder">
<span class="ui-icon">×</span> Remove “${escapeHtml(folder.name)}”
</div>
`;
contextMenu.style.display = 'block';
contextMenu.style.left = `${event.pageX + 10}px`;
contextMenu.style.top = `${event.pageY + 10}px`;
requestAnimationFrame(() => {
const rect = contextMenu.getBoundingClientRect();
if (rect.right > window.innerWidth) contextMenu.style.left = `${event.pageX - rect.width - 10}px`;
if (rect.bottom > window.innerHeight) contextMenu.style.top = `${event.pageY - rect.height - 10}px`;
});
contextMenu.querySelector('[data-action="remove-folder"]').addEventListener('click', async (clickEvent) => {
clickEvent.stopPropagation();
hideContextMenu();
if (confirm(`Remove "${folder.name}" from the list? (Files won't be deleted)`)) {
await removeFolder(folder.id);
}
});
}
function getModalImageElement() {
return document.getElementById('modalZoomImage');
}
function getModalZoomStage() {
return document.getElementById('modalZoomStage');
}
function clampModalZoom(value) {
return Math.min(MODAL_MAX_ZOOM, Math.max(MODAL_MIN_ZOOM, value));
}
function clampModalPan() {
const image = getModalImageElement();
const stage = getModalZoomStage();
if (!image || !stage) return;
if (modalZoom <= 1) {
modalPanX = 0;
modalPanY = 0;
return;
}
const scaledWidth = image.clientWidth * modalZoom;
const scaledHeight = image.clientHeight * modalZoom;
const maxPanX = Math.max(0, (scaledWidth - stage.clientWidth) / 2);
const maxPanY = Math.max(0, (scaledHeight - stage.clientHeight) / 2);
modalPanX = Math.min(maxPanX, Math.max(-maxPanX, modalPanX));
modalPanY = Math.min(maxPanY, Math.max(-maxPanY, modalPanY));
}
function applyModalZoom() {
const image = getModalImageElement();
if (!image) return;
clampModalPan();
image.style.transform = `translate(${modalPanX}px, ${modalPanY}px) scale(${modalZoom})`;
image.style.cursor = isModalPanning ? 'grabbing' : (modalZoom > 1 ? 'grab' : 'default');
const zoomLabel = document.getElementById('modalZoomLevel');
if (zoomLabel) {
zoomLabel.textContent = `${Math.round(modalZoom * 100)}%`;
}
}
function setModalZoom(nextZoom) {
modalZoom = clampModalZoom(nextZoom);
if (modalZoom <= 1) {
modalPanX = 0;
modalPanY = 0;
}
applyModalZoom();
}
function resetModalZoom() {
modalPanX = 0;
modalPanY = 0;
setModalZoom(1);
}
function clampCompareZoom(value) {
return Math.min(COMPARE_MAX_ZOOM, Math.max(COMPARE_MIN_ZOOM, value));
}
function compareViewportSize() {
const stage = document.getElementById('compareImageContainer');
return { width: stage ? stage.clientWidth / (compareViewMode === 'side-by-side' ? 2 : 1) : 0,
height: stage ? stage.clientHeight : 0 };
}
function clampComparePan() {
const { width, height } = compareViewportSize();
const maxX = width * Math.max(0, compareZoom - 1) / 2;
const maxY = height * Math.max(0, compareZoom - 1) / 2;
comparePanX = Math.min(maxX, Math.max(-maxX, comparePanX));
comparePanY = Math.min(maxY, Math.max(-maxY, comparePanY));
}
function applyCompareZoom() {
const stage = document.getElementById('compareImageContainer');
if (!stage) return;
clampComparePan();
stage.querySelectorAll('.compare-img').forEach(image => {
image.style.transform = `translate(${comparePanX}px, ${comparePanY}px) scale(${compareZoom})`;
});
stage.style.cursor = isComparePanning ? 'grabbing' : (compareZoom > 1 ? 'grab' : (compareViewMode === 'wipe' ? 'ew-resize' : 'default'));
document.getElementById('compareZoomLevel').textContent = `${Math.round(compareZoom * 100)}%`;
}
function syncCompareGeometry() {
const stage = document.getElementById('compareImageContainer');
if (!stage) return;
const { width, height } = compareViewportSize();
if (compareViewportWidth > 0) comparePanX *= width / compareViewportWidth;
if (compareViewportHeight > 0) comparePanY *= height / compareViewportHeight;
compareViewportWidth = width;
compareViewportHeight = height;
stage.querySelectorAll('.compare-img').forEach(image => {
image.style.width = `${width}px`;
image.style.height = `${height}px`;
});
applyCompareZoom();
}
function updateCompareMode() {
const stage = document.getElementById('compareImageContainer');
if (!stage) return;
stage.dataset.compareMode = compareViewMode;
stage.querySelector('.compare-img-front-wrapper').style.width = compareViewMode === 'wipe' ? `${comparePosition}%` : compareViewMode === 'side-by-side' ? '50%' : '100%';
stage.querySelector('.compare-img-front').style.opacity = compareViewMode === 'overlay' ? String(compareAmount / 100) : '1';
const slider = document.getElementById('compareSlider');
slider.hidden = compareViewMode !== 'wipe';
slider.style.left = `${comparePosition}%`;
slider.setAttribute('aria-valuenow', String(Math.round(comparePosition)));
document.getElementById('compareOverlayControl').hidden = compareViewMode !== 'overlay';
document.querySelectorAll('[data-compare-view]').forEach(button => {
const active = button.dataset.compareView === compareViewMode;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
});
const hints = {
wipe: 'Scroll to zoom · drag image to pan · drag divider to compare',
'side-by-side': 'A left · B right · scroll either pane to zoom both · drag to pan both',
difference: 'Raw difference · black pixels match · color marks change',
overlay: 'Overlay A over B · adjust A opacity above · scroll to zoom · drag to pan'
};
document.getElementById('compareHint').textContent = hints[compareViewMode];
syncCompareGeometry();
}
function comparePointerPosition(clientX, clientY) {
const stage = document.getElementById('compareImageContainer');
const rect = stage.getBoundingClientRect();
const scaleX = rect.width ? stage.offsetWidth / rect.width : 1;
const scaleY = rect.height ? stage.offsetHeight / rect.height : 1;
const { width, height } = compareViewportSize();
let x = (clientX - rect.left) * scaleX - stage.clientLeft;
if (compareViewMode === 'side-by-side' && x >= width) x -= width;
return { x, y: (clientY - rect.top) * scaleY - stage.clientTop, width, height };
}
function setCompareZoom(nextZoom, clientX = null, clientY = null) {
const previous = compareZoom;
const next = clampCompareZoom(nextZoom);
if (clientX !== null && clientY !== null && next > 1) {
const point = comparePointerPosition(clientX, clientY);
const x = point.x - point.width / 2;
const y = point.y - point.height / 2;
comparePanX = x - (x - comparePanX) / previous * next;
comparePanY = y - (y - comparePanY) / previous * next;
} else if (next > 1) {
// Toolbar and keyboard zoom anchor at the pane center.
comparePanX *= next / previous;
comparePanY *= next / previous;
}
compareZoom = next;
applyCompareZoom();
}
function resetCompareZoom() {
compareZoom = 1;
comparePanX = 0;
comparePanY = 0;
isComparePanning = false;
applyCompareZoom();
}
function initModalZoomControls() {
const zoomInBtn = document.getElementById('modalZoomIn');
const zoomOutBtn = document.getElementById('modalZoomOut');
const zoomResetBtn = document.getElementById('modalZoomReset');
const zoomStage = document.getElementById('modalZoomStage');
if (!zoomInBtn || !zoomOutBtn || !zoomResetBtn || !zoomStage || !getModalImageElement()) {
return;
}
resetModalZoom();
zoomInBtn.addEventListener('click', (e) => {
e.stopPropagation();
setModalZoom(modalZoom + MODAL_ZOOM_STEP);
});
zoomOutBtn.addEventListener('click', (e) => {
e.stopPropagation();
setModalZoom(modalZoom - MODAL_ZOOM_STEP);
});
zoomResetBtn.addEventListener('click', (e) => {
e.stopPropagation();
resetModalZoom();
});
zoomStage.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomDelta = e.deltaY < 0 ? MODAL_ZOOM_STEP : -MODAL_ZOOM_STEP;
setModalZoom(modalZoom + zoomDelta);
}, { passive: false });
zoomStage.addEventListener('mousedown', (e) => {
if (e.button !== 0 || modalZoom <= 1) return;
isModalPanning = true;
modalPanStartX = e.clientX;
modalPanStartY = e.clientY;
modalPanOriginX = modalPanX;
modalPanOriginY = modalPanY;
suppressNextModalStageClose = false;
applyModalZoom();
e.preventDefault();
});
if (!modalPanListenersBound) {
document.addEventListener('mousemove', (e) => {
if (!isModalPanning) return;
const dx = e.clientX - modalPanStartX;
const dy = e.clientY - modalPanStartY;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
suppressNextModalStageClose = true;
}
modalPanX = modalPanOriginX + dx;
modalPanY = modalPanOriginY + dy;
applyModalZoom();
});
document.addEventListener('mouseup', () => {
if (!isModalPanning) return;
isModalPanning = false;
applyModalZoom();
});
modalPanListenersBound = true;
}
}
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, 10);
if (!Number.isNaN(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 copyPromptToClipboard(promptText, promptLabel = 'Prompt') {
if (!promptText) return;
try {
await navigator.clipboard.writeText(promptText);
showNotification(`${promptLabel} copied to clipboard`);
} catch (error) {
console.error(`Error copying ${promptLabel}:`, error);
showNotification(`Failed to copy ${promptLabel.toLowerCase()}`, '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();
removeImageFromState(imageData);
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;
const totalToDelete = selectedImages.length;
const imagesToDelete = [...selectedImages];
let successCount = 0;
for (const imageData of imagesToDelete) {
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();
removeImageFromState(imageData);
successCount++;
}
} catch (error) {
console.error('Error deleting image:', error);
}
}
clearSelection();
showNotification(`Deleted ${successCount} of ${totalToDelete} 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 isSameImage(a, b) {
return a.path === b.path && a.folder_id === b.folder_id;
}
function getImageKey(path, folderId) {
return `${folderId}::${path}`;
}
function rebuildGalleryIndexes() {
const indexByKey = new Map(
currentImages.map((img, index) => [getImageKey(img.path, img.folder_id), index])
);
document.querySelectorAll('.image-card').forEach((card) => {
const key = getImageKey(card.dataset.filename, card.dataset.folderId);
const index = indexByKey.get(key);
if (typeof index === 'number') {
card.dataset.index = index;
} else {
card.removeAttribute('data-index');
}
});
}
function removeImageFromState(imageData) {
const removedIndex = currentImages.findIndex((img) => isSameImage(img, imageData));
if (removedIndex === -1) return;
currentImages.splice(removedIndex, 1);
if (currentImageIndex > removedIndex) {
currentImageIndex--;
} else if (currentImageIndex >= currentImages.length) {
currentImageIndex = currentImages.length - 1;
}
selectedImages = selectedImages.filter((img) => !isSameImage(img, imageData));
selectedForCompare = selectedForCompare.filter((img) => !isSameImage(img, imageData));
updateMultiSelectActions();
compareBtn.style.display = selectedForCompare.length === 2 ? 'block' : 'none';
if (selectedForCompare.length === 1) {
const remaining = selectedForCompare[0];
const remainingCard = document.querySelector(`[data-filename="${remaining.path}"][data-folder-id="${remaining.folder_id}"]`);
if (remainingCard) {
remainingCard.classList.remove('compare-second');
remainingCard.classList.add('compare-first');
}
}
rebuildGalleryIndexes();
}
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: isMiniMode ? 30 : 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) => {
const cardIndex = parseInt(card.dataset.index, 10);
if (Number.isNaN(cardIndex)) return;
if (isCompareMode) {
toggleCompareSelection(cardIndex, card);
} else if (e.ctrlKey || e.metaKey || isMultiSelectMode) {
if ((e.ctrlKey || e.metaKey) && !isMultiSelectMode) {
setMultiSelectMode(true);
}
toggleImageSelection(img, card, e);
} else {
openModal(cardIndex);
}
};
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;
compareEvents?.abort();
compareResizeObserver?.disconnect();
compareEvents = new AbortController();
const modal = document.getElementById('compareModal');
const container = document.getElementById('compareContainer');
const [img1, img2] = selectedForCompare;
const url = image => `/gemini-image-browser/thumbnail?filename=${encodeURIComponent(image.path)}&folder=${encodeURIComponent(image.folder_id)}`;
container.innerHTML = `
<div class="compare-wrapper">
<div class="compare-labels">
<div class="compare-label compare-label-left">A · ${escapeHtml(img1.name)}</div>
<div class="compare-label compare-label-right">B · ${escapeHtml(img2.name)}</div>
</div>
<div class="compare-controls">
<div class="compare-mode-controls" role="group" aria-label="Comparison mode">
<button type="button" data-compare-view="wipe">Wipe</button>
<button type="button" data-compare-view="side-by-side">Side by side</button>
<button type="button" data-compare-view="difference">Difference</button>
<button type="button" data-compare-view="overlay">Overlay</button>
</div>
<div class="compare-zoom-controls">
<button type="button" class="compare-zoom-btn" id="compareZoomOut" title="Zoom Out" aria-label="Zoom out">−</button>
<button type="button" class="compare-zoom-btn compare-zoom-reset" id="compareZoomReset" title="Reset Zoom" aria-label="Reset zoom and pan"><span id="compareZoomLevel">100%</span></button>
<button type="button" class="compare-zoom-btn" id="compareZoomIn" title="Zoom In" aria-label="Zoom in">+</button>
</div>
<label class="compare-overlay-control" id="compareOverlayControl" hidden>A opacity
<input type="range" id="compareOverlayAmount" min="0" max="100" value="${compareAmount}" aria-label="A overlay opacity">
<output id="compareOverlayValue">${compareAmount}%</output>
</label>
</div>
<div class="compare-image-container" id="compareImageContainer">
<div class="compare-img-back-wrapper"><img src="${url(img2)}" alt="B" class="compare-img compare-img-back"></div>
<div class="compare-img-front-wrapper"><img src="${url(img1)}" alt="A" class="compare-img compare-img-front"></div>
<div class="compare-slider" id="compareSlider" role="slider" tabindex="0" aria-label="Wipe divider" aria-valuemin="0" aria-valuemax="100"><div class="compare-slider-handle"></div></div>
</div>
<div class="compare-hint" id="compareHint"></div>
</div>`;
modal.classList.add('active');
document.body.style.overflow = 'hidden';
compareViewportWidth = 0;
compareViewportHeight = 0;
comparePosition = 50;
resetCompareZoom();
updateCompareMode();
initCompareZoomControls();
compareResizeObserver = new ResizeObserver(syncCompareGeometry);
compareResizeObserver.observe(document.getElementById('compareImageContainer'));
}
function initCompareZoomControls() {
const stage = document.getElementById('compareImageContainer');
const slider = document.getElementById('compareSlider');
const signal = compareEvents.signal;
let drag = null;
document.querySelectorAll('[data-compare-view]').forEach(button => {
button.addEventListener('click', () => {
drag = null;
isComparePanning = false;
compareViewMode = button.dataset.compareView;
updateCompareMode();
}, { signal });
});
document.getElementById('compareOverlayAmount').addEventListener('input', event => {
compareAmount = Math.max(0, Math.min(100, Number(event.target.value)));
document.getElementById('compareOverlayValue').textContent = `${compareAmount}%`;
updateCompareMode();
}, { signal });
document.getElementById('compareZoomIn').addEventListener('click', () => setCompareZoom(compareZoom + COMPARE_ZOOM_STEP), { signal });
document.getElementById('compareZoomOut').addEventListener('click', () => setCompareZoom(compareZoom - COMPARE_ZOOM_STEP), { signal });
document.getElementById('compareZoomReset').addEventListener('click', resetCompareZoom, { signal });
stage.addEventListener('wheel', event => {
event.preventDefault();
setCompareZoom(compareZoom + (event.deltaY < 0 ? COMPARE_ZOOM_STEP : -COMPARE_ZOOM_STEP), event.clientX, event.clientY);
}, { passive: false, signal });
function moveDivider(clientX) {
const rect = stage.getBoundingClientRect();
comparePosition = Math.max(0, Math.min(100, (clientX - rect.left) / rect.width * 100));
updateCompareMode();
}
stage.addEventListener('pointerdown', event => {
if (event.button !== 0) return;
const divider = compareViewMode === 'wipe' && (slider.contains(event.target) || compareZoom <= 1);
if (!divider && compareZoom <= 1) return;
drag = { id: event.pointerId, divider, x: event.clientX, y: event.clientY, panX: comparePanX, panY: comparePanY };
isComparePanning = !divider;
stage.setPointerCapture(event.pointerId);
if (divider) moveDivider(event.clientX);
applyCompareZoom();
event.preventDefault();
}, { signal });
stage.addEventListener('pointermove', event => {
if (!drag || drag.id !== event.pointerId) return;
if (drag.divider) moveDivider(event.clientX);
else {
const rect = stage.getBoundingClientRect();
comparePanX = drag.panX + (event.clientX - drag.x) * stage.offsetWidth / rect.width;
comparePanY = drag.panY + (event.clientY - drag.y) * stage.offsetHeight / rect.height;
applyCompareZoom();
}
}, { signal });
const finish = () => { drag = null; isComparePanning = false; applyCompareZoom(); };
['pointerup', 'pointercancel', 'lostpointercapture'].forEach(type => stage.addEventListener(type, finish, { signal }));
slider.addEventListener('keydown', event => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault();
comparePosition = event.key === 'Home' ? 0 : event.key === 'End' ? 100 : Math.max(0, Math.min(100, comparePosition + (event.key === 'ArrowLeft' ? -1 : 1)));
updateCompareMode();
}, { signal });
}
window.closeCompareModal = () => {
compareEvents?.abort();
compareEvents = null;
compareResizeObserver?.disconnect();
compareResizeObserver = null;
document.getElementById('compareModal').classList.remove('active');
document.body.style.overflow = '';
document.getElementById('compareContainer').innerHTML = '';
compareZoom = 1;
comparePanX = 0;
comparePanY = 0;
isComparePanning = false;
};
function setMultiSelectMode(enabled, options = {}) {
const { clear = false } = options;
isMultiSelectMode = enabled;
if (multiSelectModeBtn) {
multiSelectModeBtn.classList.toggle('active', isMultiSelectMode);
}
if (clear) {
clearSelection();
}
}
function syncGalleryScroll(index) {
const card = document.querySelector(`.image-card[data-index="${index}"]`);
if (!card) return;
card.scrollIntoView({
behavior: "instant",
block: "center",
inline: "nearest"
});
}
function renderModalFilmstrip() {
const strip = document.getElementById('modalFilmstrip');
const count = document.getElementById('filmstripCount');
if (!strip || !count || currentImageIndex < 0) return;
const windowSize = 17;
const centeredStart = currentImageIndex - Math.floor(windowSize / 2);
const start = Math.min(
Math.max(0, centeredStart),
Math.max(0, currentImages.length - windowSize)
);
const end = Math.min(currentImages.length, start + windowSize);
strip.innerHTML = currentImages.slice(start, end).map((item, offset) => {
const index = start + offset;
const url = `/gemini-image-browser/thumbnail?filename=${encodeURIComponent(item.path)}&folder=${item.folder_id}`;
const isVideo = /\.(mp4|webm|mov)$/i.test(item.path);
const isAudio = /\.(mp3|wav|ogg|flac|m4a)$/i.test(item.path);
const preview = isAudio
? '<span aria-hidden="true">♪</span>'
: (isVideo ? `<video src="${url}" muted preload="metadata"></video>` : `<img src="${url}" alt="">`);
return `<button type="button" class="filmstrip-thumb${index === currentImageIndex ? ' active' : ''}" data-filmstrip-index="${index}" title="${escapeHtml(item.name)}">${preview}</button>`;
}).join('');
count.textContent = `${currentImageIndex + 1} / ${currentImages.length}`;
strip.querySelectorAll('[data-filmstrip-index]').forEach((button) => {
button.addEventListener('click', () => openModal(Number(button.dataset.filmstripIndex)));
});
requestAnimationFrame(() => strip.querySelector('.filmstrip-thumb.active')?.scrollIntoView({ block: 'nearest', inline: 'center' }));
}
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');
const viewerTitle = document.getElementById('viewerTitle');
const viewerLocation = document.getElementById('viewerLocation');
if (viewerTitle) viewerTitle.textContent = imageData.name;
if (viewerLocation) viewerLocation.textContent = `${currentLocation?.textContent || 'Studio Library'} · ${index + 1} of ${currentImages.length}`;
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 = `
<div class="modal-zoom-controls">
<button class="modal-zoom-btn" id="modalZoomOut" title="Zoom Out">-</button>
<button class="modal-zoom-btn modal-zoom-btn-reset" id="modalZoomReset" title="Reset Zoom"><span id="modalZoomLevel">100%</span></button>
<button class="modal-zoom-btn" id="modalZoomIn" title="Zoom In">+</button>
</div>
<div class="modal-zoom-stage" id="modalZoomStage">
<img id="modalZoomImage" src="${url}" alt="">
</div>
`;
initModalZoomControls();
}
modal.classList.add('active');
document.body.style.overflow = 'hidden';
renderModalFilmstrip();
syncGalleryScroll(index);
const isFavorite = imageData.is_favorite;
metadataPanel.innerHTML = `
<div class="modal-header-actions">
<button id="modalFavoriteBtn" class="modal-action-btn ${isFavorite ? 'favorite' : ''}" onclick="toggleModalFavorite()">
<span class="ui-icon">${isFavorite ? '★' : '☆'}</span>
${isFavorite ? 'Favorited' : 'Add to Favorites'}
</button>
<div class="modal-view-toggle">
<button id="metadataViewBtn" class="modal-view-btn active" onclick="switchMetadataView('metadata')">Details</button>
<button id="workflowViewBtn" class="modal-view-btn" onclick="switchMetadataView('workflow')">Nodes</button>
</div>
</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">N</span> Workflow Node Inspector
</div>
<div class="metadata-section-content" id="workflowNodesSection">
<p style="color: #888; margin: 0;">Loading...</p>
</div>
</div>
<button class="modal-delete-btn" onclick="deleteCurrentImage()">Delete Image</button>
`;
const workflowContent = document.getElementById('workflowNodesSection');
const workflowSection = workflowContent ? workflowContent.closest('.metadata-section') : null;
if (workflowSection) {
workflowSection.id = 'workflowNodesView';
workflowSection.classList.add('workflow-section');
workflowSection.style.display = 'none';
const workflowHeader = workflowSection.querySelector('.metadata-section-header');
if (workflowHeader) {
workflowHeader.innerHTML = '<span class="icon ui-icon">N</span> Workflow Node Inspector';
}
}
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>`;
if (workflowContent) {
workflowContent.innerHTML = `<p style="color: #dc3545; margin: 0;">Error loading workflow nodes: ${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 = '';
modalZoom = 1;
modalPanX = 0;
modalPanY = 0;
isModalPanning = false;
suppressNextModalStageClose = false;
};
document.getElementById('modal').addEventListener('click', (e) => {
if (e.target.id === 'modalZoomStage' && suppressNextModalStageClose) {
suppressNextModalStageClose = false;
return;
}
if (e.target.id === 'modal' || e.target.id === 'modalMediaContainer' || e.target.id === 'modalZoomStage') {
closeModal();
}
});
window.switchMetadataView = (view) => {
const showWorkflow = view === 'workflow';
const metadataSections = document.querySelectorAll('#metadataPanel .metadata-section:not(.workflow-section)');
const workflowSection = document.getElementById('workflowNodesView');
const metadataBtn = document.getElementById('metadataViewBtn');
const workflowBtn = document.getElementById('workflowViewBtn');
metadataSections.forEach((section) => {
section.style.display = showWorkflow ? 'none' : '';
});
if (workflowSection) {
workflowSection.style.display = showWorkflow ? '' : 'none';
}
if (metadataBtn) metadataBtn.classList.toggle('active', !showWorkflow);
if (workflowBtn) workflowBtn.classList.toggle('active', showWorkflow);
};
function renderWorkflowNodes(workflowNodes) {
const container = document.getElementById('workflowNodesSection');
if (!container) return;
if (!Array.isArray(workflowNodes) || workflowNodes.length === 0) {
container.innerHTML = '<p class="workflow-empty">No workflow node data found in this file.</p>';
return;
}
container.innerHTML = `<div class="workflow-search"><input id="workflowNodeSearch" placeholder="Filter ${workflowNodes.length} nodes"></div><div id="workflowNodeList"></div>`;
const list = document.getElementById('workflowNodeList');
const render = (filter = '') => {
const term = filter.trim().toLowerCase();
const visible = workflowNodes.filter((node) => !term || `${node.title || ''} ${node.type || ''} ${node.id || ''} ${JSON.stringify(node.params || [])}`.toLowerCase().includes(term));
list.innerHTML = visible.map((node, index) => {
const title = escapeHtml(node.title || node.type || 'Unknown');
const nodeType = escapeHtml(node.type || 'Unknown');
const nodeId = escapeHtml(node.id || 'N/A');
const params = Array.isArray(node.params) ? node.params : [];
const paramsHtml = params.length
? params.map((param) => `<li><span class="workflow-param-name">${escapeHtml(param.name || 'param')}</span><span class="workflow-param-value">${escapeHtml(String(param.value ?? ''))}</span></li>`).join('')
: '<li><span class="workflow-param-value">No saved parameters</span></li>';
const mutedClass = Number(node.mode || 0) === 0 ? '' : ' muted';
return `<details class="workflow-node-item${mutedClass}">
<summary class="workflow-node-header"><span class="workflow-node-index">${index + 1}</span><span class="workflow-node-title"><strong>${title}</strong><small>${nodeType} · ID ${nodeId} · ${params.length} params</small></span><span class="workflow-node-chevron">›</span></summary>
<ul class="workflow-node-params">${paramsHtml}</ul>
</details>`;
}).join('') || '<p class="workflow-empty">No nodes match that filter.</p>';
};
render();
document.getElementById('workflowNodeSearch').addEventListener('input', (event) => render(event.target.value));
}
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-row">
<div class="metadata-label">Positive Prompt</div>
<button class="metadata-copy-btn" type="button" data-prompt-kind="positive">Copy</button>
</div>
<div class="metadata-prompt">${escapeHtml(parsed.prompt)}</div>
</div>
`;
}
if (parsed.negative_prompt) {
promptsHtml += `
<div class="metadata-item">
<div class="metadata-label-row">
<div class="metadata-label">Negative Prompt</div>
<button class="metadata-copy-btn" type="button" data-prompt-kind="negative">Copy</button>
</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;
document.querySelectorAll('#promptsSection .metadata-copy-btn').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const promptKind = btn.dataset.promptKind;
const promptText = promptKind === 'negative' ? parsed.negative_prompt : parsed.prompt;
const promptLabel = promptKind === 'negative' ? 'Negative prompt' : 'Positive prompt';
await copyPromptToClipboard(promptText, promptLabel);
});
});
let settingsHtml = '<div class="settings-grid">';
const size = dimensions?.width && dimensions?.height
? `${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 !== null && setting.value !== undefined && setting.value !== '') {
settingsHtml += `
<div class="metadata-item ${setting.fullWidth ? 'full-width' : ''}">
<div class="metadata-label-row"><div class="metadata-label">${setting.label}</div>${setting.label === 'Seed' ? '<button class="metadata-copy-btn" type="button" data-copy-seed>Copy</button>' : ''}</div>
<div class="metadata-value">${escapeHtml(setting.value)}</div>
</div>
`;
}
});
settingsHtml += '</div>';
if (data.extra && Object.keys(data.extra).length) {
settingsHtml += `<div class="metadata-extra-grid">${Object.entries(data.extra).map(([label, value]) => `<div class="metadata-kv"><div class="label">${escapeHtml(label)}</div><div class="value">${escapeHtml(String(value))}</div></div>`).join('')}</div>`;
}
document.getElementById('settingsSection').innerHTML = settingsHtml;
document.querySelector('[data-copy-seed]')?.addEventListener('click', () => copyPromptToClipboard(String(parsed.seed), 'Seed'));
let lorasHtml = '';
if (parsed.loras && parsed.loras.length > 0) {
parsed.loras.forEach(lora => {
lorasHtml += `
<div class="lora-item">
<div class="metadata-label-row"><div class="lora-name">${escapeHtml(lora.name)}</div><button class="metadata-copy-btn" type="button" data-copy-lora="${escapeHtml(lora.name)}">Copy</button></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;
document.querySelectorAll('[data-copy-lora]').forEach((button) => button.addEventListener('click', () => copyPromptToClipboard(button.dataset.copyLora, 'LoRA name')));
renderWorkflowNodes(data.workflow_nodes || []);
}
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.getElementById('modalFavoriteBtn');
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();
removeImageFromState(imageData);
closeModal();
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');
}
};
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;
showFavoritesOnly = false;
loadImages(true);
};
item.appendChild(content);
item.addEventListener('contextmenu', (event) => showFolderContextMenu(event, folder));
folderList.appendChild(item);
});
updateWorkspaceLocation();
}
function updateWorkspaceLocation() {
const activeFolder = availableFolders.find((folder) => folder.id === currentFolder);
const label = showFavoritesOnly ? 'Favorites' : (activeFolder?.name || 'All Images');
if (currentLocation) currentLocation.textContent = label;
if (currentPath) currentPath.textContent = showFavoritesOnly ? 'Smart Collection' : 'Studio Library';
favoritesFilterBtn?.classList.toggle('active', showFavoritesOnly);
favoritesToolbarBtn?.classList.toggle('active', showFavoritesOnly);
allImagesBtn?.classList.toggle('active', !showFavoritesOnly && currentFolder === 'default');
}
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 validThemes = ['graphite', 'nier'];
const nextTheme = validThemes.includes(theme) ? theme : 'graphite';
document.body.dataset.theme = nextTheme;
if (themeSelect) themeSelect.value = nextTheme;
localStorage.setItem('studioTheme', nextTheme);
}
searchInput.addEventListener('input', debounce((e) => {
currentSearch = e.target.value;
loadImages(true);
}, 300));
sortSelect.addEventListener('change', (e) => {
currentSort = e.target.value;
loadImages(true);
});
const toggleFavoritesFilter = () => {
showFavoritesOnly = !showFavoritesOnly;
updateWorkspaceLocation();
loadImages(true);
};
favoritesFilterBtn.addEventListener('click', toggleFavoritesFilter);
favoritesToolbarBtn?.addEventListener('click', toggleFavoritesFilter);
allImagesBtn?.addEventListener('click', () => {
currentFolder = 'default';
showFavoritesOnly = false;
updateWorkspaceLocation();
loadImages(true);
});
refreshBtn.addEventListener('click', () => loadImages(true));
addFolderBtn.addEventListener('click', addNewFolder);
if (themeSelect) {
applyTheme(localStorage.getItem('studioTheme') || 'graphite');
themeSelect.addEventListener('change', (event) => applyTheme(event.target.value));
}
if (studioShell && sidebarCollapseBtn) {
const setSidebarCollapsed = (collapsed) => {
studioShell.classList.toggle('sidebar-collapsed', collapsed);
sidebarCollapseBtn.textContent = collapsed ? '›' : '‹';
sidebarCollapseBtn.title = collapsed ? 'Expand sidebar' : 'Collapse sidebar';
sidebarCollapseBtn.setAttribute('aria-label', sidebarCollapseBtn.title);
sidebarCollapseBtn.setAttribute('aria-expanded', String(!collapsed));
localStorage.setItem('studioSidebarCollapsed', String(collapsed));
};
setSidebarCollapsed(localStorage.getItem('studioSidebarCollapsed') === 'true');
sidebarCollapseBtn.addEventListener('click', () => {
const next = !studioShell.classList.contains('sidebar-collapsed');
setSidebarCollapsed(next);
});
}
if (thumbnailSize) {
const savedSize = Number(localStorage.getItem('studioThumbnailSize')) || 220;
thumbnailSize.value = String(savedSize);
document.documentElement.style.setProperty('--thumb-size', `${savedSize}px`);
thumbnailSize.addEventListener('input', () => {
const size = Number(thumbnailSize.value);
document.documentElement.style.setProperty('--thumb-size', `${size}px`);
localStorage.setItem('studioThumbnailSize', String(size));
});
}
// --- Open Mode Switcher ---
const openModeSelect = document.getElementById('openModeSelect');
if (openModeSelect) {
const savedMode = localStorage.getItem('geminiImageBrowserOpenMode') || 'tab';
openModeSelect.value = savedMode;
openModeSelect.addEventListener('change', (e) => {
const newMode = e.target.value;
localStorage.setItem('geminiImageBrowserOpenMode', newMode);
if (isMiniMode && newMode === 'tab') {
// Switch from mini to tab: tell parent to close iframe + open tab
window.parent.postMessage({ type: 'gemini-browser-switch-to-tab' }, '*');
} else if (!isMiniMode && newMode === 'mini') {
// User switched to mini from a full tab — just save the preference.
// Next time they click the button in ComfyUI, it will open as mini.
showNotification('Next time you open Image Browser, it will use Mini Window mode.');
}
});
}
compareModeBtn.addEventListener('click', () => {
isCompareMode = !isCompareMode;
compareModeBtn.classList.toggle('active', isCompareMode);
if (isCompareMode) {
setMultiSelectMode(false, { clear: true });
}
if (!isCompareMode) {
selectedForCompare = [];
compareBtn.style.display = 'none';
document.querySelectorAll('.selected-for-compare').forEach(card => {
card.classList.remove('selected-for-compare', 'compare-first', 'compare-second');
});
}
});
if (multiSelectModeBtn) {
multiSelectModeBtn.addEventListener('click', () => {
const nextMode = !isMultiSelectMode;
setMultiSelectMode(nextMode, { clear: !nextMode });
if (isMultiSelectMode && isCompareMode) {
isCompareMode = false;
compareModeBtn.classList.remove('active');
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', () => setMultiSelectMode(false, { clear: true }));
folderBtn.addEventListener('click', (e) => {
e.stopPropagation();
const expanded = folderDropdown.classList.toggle('active');
folderBtn.classList.toggle('active', expanded);
folderBtn.setAttribute('aria-expanded', String(expanded));
});
document.addEventListener('click', (e) => {
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 compareModal = document.getElementById('compareModal');
const isModalOpen = modal && modal.classList.contains('active');
const isCompareModalOpen = compareModal && compareModal.classList.contains('active');
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
if (e.key === 'Escape') {
if (isModalOpen) {
closeModal();
}
if (isCompareModalOpen) {
closeCompareModal();
}
hideContextMenu();
if (selectedImages.length > 0) {
clearSelection();
}
} else if (isCompareModalOpen && (e.key === '+' || e.key === '=')) {
e.preventDefault();
setCompareZoom(compareZoom + COMPARE_ZOOM_STEP);
} else if (isCompareModalOpen && (e.key === '-' || e.key === '_')) {
e.preventDefault();
setCompareZoom(compareZoom - COMPARE_ZOOM_STEP);
} else if (isCompareModalOpen && e.key === '0') {
e.preventDefault();
resetCompareZoom();
} else if (isModalOpen && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
e.preventDefault();
if (e.key === 'ArrowLeft') {
navigatePrev();
} else if (e.key === 'ArrowRight') {
navigateNext();
}
} else if (isModalOpen && (e.key === '+' || e.key === '=')) {
if (getModalImageElement()) {
e.preventDefault();
setModalZoom(modalZoom + MODAL_ZOOM_STEP);
}
} else if (isModalOpen && (e.key === '-' || e.key === '_')) {
if (getModalImageElement()) {
e.preventDefault();
setModalZoom(modalZoom - MODAL_ZOOM_STEP);
}
} else if (isModalOpen && e.key === '0') {
if (getModalImageElement()) {
e.preventDefault();
resetModalZoom();
}
}
});
function escapeHtml(text) {
if (text == null) return '';
return String(text).replace(/[&<>"']/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[m]);
}
loadImages(true);
});