1796 lines
77 KiB
JavaScript
1796 lines
77 KiB
JavaScript
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[m]);
|
||
}
|
||
|
||
loadImages(true);
|
||
});
|