3698 lines
194 KiB
JavaScript
3698 lines
194 KiB
JavaScript
class GalleryManager {
|
||
constructor() {
|
||
this.images = [];
|
||
this.currentPage = 1;
|
||
this.limit = 100;
|
||
this.total = 0;
|
||
this.viewMode = 'grid'; // 'grid' or 'list'
|
||
this.viewer = null;
|
||
this.currentSubfolder = '';
|
||
this.expandedFolders = new Set();
|
||
this.thumbSize = 'md'; // sm, md, lg
|
||
this.infiniteScroll = false;
|
||
this.loadingMore = false;
|
||
// Duplicate scan: a server-side job the modal follows (see scanDuplicates)
|
||
this.dupScan = ScanProgress.initialState();
|
||
this.dupSource = null;
|
||
this.dupResults = null;
|
||
|
||
this.api = ApiClient.createApiClient();
|
||
// Only the newest page request may update the grid
|
||
this.imagesRequest = ApiClient.latestOnly();
|
||
|
||
// Elements rendered with data-action="..." are dispatched here instead of through
|
||
// inline onclick handlers, so file names and URLs never land inside JavaScript source.
|
||
this.actionHandlers = {
|
||
"open-url": ({ url }) => window.open(url, '_blank'),
|
||
"copy-url": ({ url, label }, el) => this.copyUrlWithFeedback(el, url, label),
|
||
"select-suggestion": ({ tag }) => this.selectSuggestion(tag),
|
||
"remove-tag": ({ index }) => this.removeTag(index),
|
||
"remove-review-tag": ({ index }) => this.removeReviewTag(index),
|
||
};
|
||
|
||
this.initializeEventListeners();
|
||
this.applySettings(this.getSettings());
|
||
this.loadFolderTree();
|
||
this.loadImages();
|
||
this.resumeDuplicateScan();
|
||
|
||
// Check thumbnails at startup if enabled
|
||
this.checkThumbnailsAtStartup();
|
||
}
|
||
|
||
handleActionClick(e) {
|
||
const target = e.target.closest('[data-action]');
|
||
if (target) DataActions.dispatch(target.dataset, this.actionHandlers, target);
|
||
}
|
||
|
||
copyUrlWithFeedback(el, url, label) {
|
||
navigator.clipboard.writeText(url).then(() => {
|
||
el.textContent = '✓ Copied!';
|
||
setTimeout(() => { el.textContent = label; }, 1000);
|
||
});
|
||
}
|
||
|
||
handleMediaError(img) {
|
||
if (!img || img.tagName !== 'IMG' || !img.dataset || img.dataset.original === undefined) return;
|
||
const next = ImageHelpers.fallbackImageSource(img.getAttribute('src'), img.dataset);
|
||
if (next) {
|
||
// The thumbnail failed (stale cache, half-written file): show the original instead
|
||
img.dataset.fellBack = '1';
|
||
img.src = next;
|
||
return;
|
||
}
|
||
img.style.display = 'none';
|
||
const fallback = img.nextElementSibling;
|
||
if (fallback && fallback.dataset && fallback.dataset.mediaFallback !== undefined) {
|
||
fallback.style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
initializeEventListeners() {
|
||
document.addEventListener('click', (e) => this.handleActionClick(e));
|
||
// Image load failures do not bubble; capture them once for every card
|
||
document.addEventListener('error', (e) => this.handleMediaError(e.target), true);
|
||
document.getElementById('refreshBtn').addEventListener('click', () => this.loadImages());
|
||
document.getElementById('limitSelector').addEventListener('change', (e) => this.changeLimit(parseInt(e.target.value)));
|
||
document.getElementById('gridViewBtn').addEventListener('click', () => this.setViewMode('grid'));
|
||
document.getElementById('listViewBtn').addEventListener('click', () => this.setViewMode('list'));
|
||
document.getElementById('settingsBtn').addEventListener('click', () => this.showSettings());
|
||
document.getElementById('autoTagBtn').addEventListener('click', () => this.showAutoTagModal());
|
||
document.getElementById('addPromptBtn').addEventListener('click', () => this.showAddPromptModal());
|
||
document.getElementById('retryBtn').addEventListener('click', () => this.loadImages());
|
||
document.getElementById('prevPageBtn').addEventListener('click', () => this.previousPage());
|
||
document.getElementById('nextPageBtn').addEventListener('click', () => this.nextPage());
|
||
document.getElementById('pageInput').addEventListener('change', (e) => this.goToPage(parseInt(e.target.value)));
|
||
document.getElementById('pageInput').addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') {
|
||
this.goToPage(parseInt(e.target.value));
|
||
}
|
||
});
|
||
|
||
// Settings modal event listeners
|
||
document.getElementById('closeSettingsBtn').addEventListener('click', () => this.hideSettings());
|
||
document.getElementById('saveSettingsBtn').addEventListener('click', () => this.saveSettings());
|
||
document.getElementById('resetSettingsBtn').addEventListener('click', () => this.resetSettings());
|
||
document.getElementById('generateThumbnailsBtn').addEventListener('click', () => this.generateThumbnails());
|
||
document.getElementById('clearCacheBtn').addEventListener('click', () => this.clearCache());
|
||
document.getElementById('rescanFolderBtn').addEventListener('click', () => this.rescanFolder());
|
||
document.getElementById('scanDuplicatesBtn').addEventListener('click', () => this.scanDuplicates());
|
||
document.getElementById('dupScanPill').addEventListener('click', () => this.openDuplicatesModal());
|
||
document.getElementById('galleryScroll').addEventListener('scroll', () => this.maybeLoadMore(), { passive: true });
|
||
|
||
// Duplicates modal event listeners
|
||
document.getElementById('closeDuplicatesBtn').addEventListener('click', () => this.hideDuplicatesModal());
|
||
document.getElementById('selectAllBtn').addEventListener('click', () => this.selectAllDuplicates());
|
||
document.getElementById('removeDuplicatesBtn').addEventListener('click', () => this.removeDuplicates());
|
||
|
||
// Close modal on outside click
|
||
document.getElementById('settingsModal').addEventListener('click', (e) => {
|
||
if (e.target.id === 'settingsModal') {
|
||
this.hideSettings();
|
||
}
|
||
});
|
||
|
||
document.getElementById('duplicatesModal').addEventListener('click', (e) => {
|
||
if (e.target.id === 'duplicatesModal') {
|
||
this.hideDuplicatesModal();
|
||
}
|
||
});
|
||
|
||
// Auto Tag modal event listeners
|
||
document.getElementById('startAutoTagBtn').addEventListener('click', () => this.startAutoTag());
|
||
document.getElementById('startReviewBtn').addEventListener('click', () => this.startReview());
|
||
document.getElementById('skipReviewBtn').addEventListener('click', () => this.skipReviewImage());
|
||
document.getElementById('applyReviewBtn').addEventListener('click', () => this.applyReviewTags());
|
||
document.getElementById('cancelAutoTagBtn').addEventListener('click', () => this.cancelAutoTag());
|
||
document.getElementById('cancelDownloadBtn').addEventListener('click', () => this.cancelDownload());
|
||
document.getElementById('unloadModelBtn').addEventListener('click', () => this.unloadModel());
|
||
|
||
// WD14 model selection toggle (prompt vs thresholds)
|
||
document.querySelectorAll('input[name="autoTagModel"]').forEach(radio => {
|
||
radio.addEventListener('change', (e) => {
|
||
const isWd14 = e.target.value.startsWith('wd14');
|
||
document.getElementById('promptSection').classList.toggle('hidden', isWd14);
|
||
document.getElementById('wd14ThresholdSection').classList.toggle('hidden', !isWd14);
|
||
});
|
||
});
|
||
|
||
// WD14 threshold slider value display
|
||
document.getElementById('wd14GeneralThreshold').addEventListener('input', (e) => {
|
||
document.getElementById('wd14GeneralThresholdValue').textContent = parseFloat(e.target.value).toFixed(2);
|
||
});
|
||
document.getElementById('wd14CharacterThreshold').addEventListener('input', (e) => {
|
||
document.getElementById('wd14CharacterThresholdValue').textContent = parseFloat(e.target.value).toFixed(2);
|
||
});
|
||
|
||
// Add Prompt modal event listeners
|
||
document.getElementById('saveNewPromptBtn').addEventListener('click', () => this.saveNewPrompt());
|
||
document.getElementById('addPromptTagInput').addEventListener('input', (e) => this.handleTagInput(e));
|
||
document.getElementById('addPromptTagInput').addEventListener('keydown', (e) => this.handleTagKeydown(e));
|
||
document.getElementById('clearRatingBtn').addEventListener('click', () => this.clearRating());
|
||
|
||
// Rating stars click handlers
|
||
document.querySelectorAll('#addPromptRating .rating-star').forEach(star => {
|
||
star.addEventListener('click', (e) => this.setRating(parseInt(e.target.dataset.rating)));
|
||
});
|
||
|
||
// Close AutoTag modals on outside click
|
||
['autoTagModal', 'autoTagReviewModal', 'autoTagProgressModal', 'autoTagDownloadModal', 'addPromptModal'].forEach(modalId => {
|
||
document.getElementById(modalId).addEventListener('click', (e) => {
|
||
if (e.target.id === modalId) {
|
||
this.hideModal(modalId);
|
||
}
|
||
});
|
||
});
|
||
|
||
// Thumbnail size buttons
|
||
['Sm', 'Md', 'Lg'].forEach(size => {
|
||
const btn = document.getElementById('thumb' + size + 'Btn');
|
||
if (btn) btn.addEventListener('click', () => this.setThumbSize(size.toLowerCase()));
|
||
});
|
||
|
||
// Folder tree events
|
||
document.getElementById('clearFolderFilterBtn').addEventListener('click', () => {
|
||
this.currentSubfolder = '';
|
||
this.renderFolderTree();
|
||
this.loadImages(1);
|
||
});
|
||
|
||
// Resize handle for folder panel
|
||
const resizeHandle = document.getElementById('folderResizeHandle');
|
||
if (resizeHandle) {
|
||
let isResizing = false;
|
||
resizeHandle.addEventListener('mousedown', (e) => {
|
||
isResizing = true;
|
||
document.body.style.cursor = 'col-resize';
|
||
document.body.style.userSelect = 'none';
|
||
e.preventDefault();
|
||
});
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (!isResizing) return;
|
||
const panel = document.getElementById('folderTreePanel');
|
||
const newWidth = Math.max(160, Math.min(500, e.clientX - panel.getBoundingClientRect().left));
|
||
panel.style.width = newWidth + 'px';
|
||
});
|
||
document.addEventListener('mouseup', () => {
|
||
if (isResizing) {
|
||
isResizing = false;
|
||
document.body.style.cursor = '';
|
||
document.body.style.userSelect = '';
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
async loadFolderTree() {
|
||
try {
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/gallery/subfolders?include_ancestors=true');
|
||
if (!response.ok) return;
|
||
const data = await response.json();
|
||
if (data.success && data.subfolders.length > 0) {
|
||
this.folderList = data.subfolders;
|
||
document.getElementById('folderTreePanel').classList.remove('hidden');
|
||
const rh = document.getElementById('folderResizeHandle');
|
||
if (rh) rh.classList.remove('hidden');
|
||
this.renderFolderTree();
|
||
}
|
||
} catch (error) {
|
||
console.error('Folder tree error:', error);
|
||
}
|
||
}
|
||
|
||
renderFolderTree() {
|
||
const container = document.getElementById('folderTree');
|
||
container.innerHTML = '';
|
||
|
||
// Build tree structure from flat sorted paths
|
||
const tree = {};
|
||
for (const path of this.folderList) {
|
||
const parts = path.replace(/\\/g, '/').split('/');
|
||
let node = tree;
|
||
for (const part of parts) {
|
||
if (!node[part]) node[part] = {};
|
||
node = node[part];
|
||
}
|
||
}
|
||
|
||
const renderNode = (obj, parentPath, depth) => {
|
||
const entries = Object.keys(obj).sort();
|
||
for (const name of entries) {
|
||
const fullPath = parentPath ? parentPath + '/' + name : name;
|
||
const hasChildren = Object.keys(obj[name]).length > 0;
|
||
const isExpanded = this.expandedFolders.has(fullPath);
|
||
const isActive = this.currentSubfolder === fullPath;
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'flex items-center px-2 py-1 cursor-pointer hover:bg-pm-hover transition-colors'
|
||
+ (isActive ? ' bg-pm-accent/10 text-pm-accent font-medium' : ' text-pm');
|
||
row.style.paddingLeft = (8 + depth * 16) + 'px';
|
||
|
||
// Toggle arrow
|
||
const arrow = document.createElement('span');
|
||
arrow.className = 'inline-block w-4 text-center text-pm-secondary mr-1 select-none';
|
||
if (hasChildren) {
|
||
arrow.textContent = isExpanded ? '\u25BE' : '\u25B8';
|
||
arrow.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
if (this.expandedFolders.has(fullPath)) {
|
||
this.expandedFolders.delete(fullPath);
|
||
} else {
|
||
this.expandedFolders.add(fullPath);
|
||
}
|
||
this.renderFolderTree();
|
||
});
|
||
} else {
|
||
arrow.textContent = '';
|
||
}
|
||
row.appendChild(arrow);
|
||
|
||
const label = document.createElement('span');
|
||
label.textContent = name;
|
||
label.className = 'truncate';
|
||
row.appendChild(label);
|
||
|
||
// Click to filter
|
||
row.addEventListener('click', () => {
|
||
this.currentSubfolder = fullPath;
|
||
this.renderFolderTree();
|
||
this.loadImages(1);
|
||
});
|
||
|
||
container.appendChild(row);
|
||
|
||
if (hasChildren && isExpanded) {
|
||
renderNode(obj[name], fullPath, depth + 1);
|
||
}
|
||
}
|
||
};
|
||
|
||
// "All" option at top
|
||
const allRow = document.createElement('div');
|
||
allRow.className = 'flex items-center px-2 py-1 cursor-pointer hover:bg-pm-hover transition-colors'
|
||
+ (!this.currentSubfolder ? ' bg-pm-accent/10 text-pm-accent font-medium' : ' text-pm');
|
||
allRow.style.paddingLeft = '8px';
|
||
allRow.innerHTML = '<span class="inline-block w-4 mr-1"></span><span>All Images</span>';
|
||
allRow.addEventListener('click', () => {
|
||
this.currentSubfolder = '';
|
||
this.renderFolderTree();
|
||
this.loadImages(1);
|
||
});
|
||
container.appendChild(allRow);
|
||
|
||
renderNode(tree, '', 0);
|
||
}
|
||
|
||
setThumbSize(size) {
|
||
this.thumbSize = size;
|
||
const grid = document.getElementById('galleryGrid');
|
||
const sizes = {
|
||
sm: { cols: 'repeat(auto-fill, minmax(100px, 1fr))', gap: '4px' },
|
||
md: { cols: 'repeat(auto-fill, minmax(180px, 1fr))', gap: '8px' },
|
||
lg: { cols: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '12px' },
|
||
};
|
||
const s = sizes[size] || sizes.md;
|
||
grid.classList.remove('gallery-columns-custom'); // an explicit size wins over the column setting
|
||
grid.style.gridTemplateColumns = s.cols;
|
||
grid.style.gap = s.gap;
|
||
// Update button states
|
||
['Sm', 'Md', 'Lg'].forEach(s => {
|
||
const btn = document.getElementById('thumb' + s + 'Btn');
|
||
if (btn) {
|
||
if (s.toLowerCase() === size) {
|
||
btn.className = btn.className.replace('bg-pm-input', 'bg-pm-accent').replace('text-pm ', 'text-pm-accent-fg ');
|
||
} else {
|
||
btn.className = btn.className.replace('bg-pm-accent', 'bg-pm-input').replace('text-pm-accent-fg', 'text-pm');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Load one page of images. `page` is committed to this.currentPage only after the
|
||
* server answered, and a newer request supersedes an older one still in flight.
|
||
*/
|
||
async loadImages(page = this.currentPage, { append = false } = {}) {
|
||
if (!append) this.showLoading();
|
||
else document.getElementById('loadingStatus').textContent = 'Loading more...';
|
||
|
||
const offset = (page - 1) * this.limit;
|
||
let url = `/prompt_manager/images/output?limit=${this.limit}&offset=${offset}`;
|
||
if (this.currentSubfolder) {
|
||
url += `&subfolder=${encodeURIComponent(this.currentSubfolder)}`;
|
||
}
|
||
|
||
try {
|
||
const data = await this.imagesRequest((signal) => this.api.get(url, { signal }));
|
||
if (data === ApiClient.STALE) return;
|
||
|
||
const next = ListState.nextPageState(
|
||
{ page: this.currentPage, limit: this.limit, total: this.total },
|
||
{ page, total: data.total },
|
||
);
|
||
this.currentPage = next.page;
|
||
this.total = next.total;
|
||
const images = data.images.map(image => ({
|
||
...image,
|
||
url: ApiClient.resolveUrl(image.url),
|
||
thumbnail_url: ApiClient.resolveUrl(image.thumbnail_url),
|
||
}));
|
||
const from = append ? this.images.length : 0;
|
||
this.images = append ? this.images.concat(images) : images;
|
||
this.updateStats();
|
||
this.renderGallery({ from });
|
||
this.updatePagination();
|
||
document.getElementById('loadingStatus').textContent = 'Loaded';
|
||
this.maybeLoadMore(); // a short page may not fill the viewport
|
||
} catch (error) {
|
||
console.error('Error loading images:', error);
|
||
this.showError(error.message);
|
||
}
|
||
}
|
||
|
||
maybeLoadMore() {
|
||
const el = document.getElementById('galleryScroll');
|
||
if (!el) return;
|
||
const wanted = ListState.shouldLoadMore({
|
||
infiniteScroll: this.infiniteScroll,
|
||
loading: this.loadingMore,
|
||
page: this.currentPage,
|
||
limit: this.limit,
|
||
total: this.total,
|
||
scrollTop: el.scrollTop,
|
||
clientHeight: el.clientHeight,
|
||
scrollHeight: el.scrollHeight,
|
||
});
|
||
if (wanted) this.loadMoreImages();
|
||
}
|
||
|
||
async loadMoreImages() {
|
||
if (this.loadingMore) return;
|
||
this.loadingMore = true;
|
||
try {
|
||
await this.loadImages(this.currentPage + 1, { append: true });
|
||
} finally {
|
||
this.loadingMore = false;
|
||
}
|
||
}
|
||
|
||
showLoading() {
|
||
document.getElementById('loadingState').classList.remove('hidden');
|
||
document.getElementById('errorState').classList.add('hidden');
|
||
document.getElementById('galleryGrid').classList.add('hidden');
|
||
document.getElementById('galleryList').classList.add('hidden');
|
||
document.getElementById('paginationControls').classList.add('hidden');
|
||
document.getElementById('loadingStatus').textContent = 'Loading...';
|
||
}
|
||
|
||
showError(message) {
|
||
document.getElementById('loadingState').classList.add('hidden');
|
||
document.getElementById('errorState').classList.remove('hidden');
|
||
document.getElementById('galleryGrid').classList.add('hidden');
|
||
document.getElementById('galleryList').classList.add('hidden');
|
||
document.getElementById('paginationControls').classList.add('hidden');
|
||
document.getElementById('errorMessage').textContent = message;
|
||
document.getElementById('loadingStatus').textContent = 'Error';
|
||
}
|
||
|
||
updateStats() {
|
||
const start = this.infiniteScroll ? 1 : (this.currentPage - 1) * this.limit + 1;
|
||
const end = this.infiniteScroll ? this.images.length : Math.min(this.currentPage * this.limit, this.total);
|
||
|
||
document.getElementById('showingStart').textContent = this.images.length > 0 ? start : 0;
|
||
document.getElementById('showingEnd').textContent = end;
|
||
document.getElementById('totalImages').textContent = this.total;
|
||
}
|
||
|
||
setViewMode(mode) {
|
||
this.viewMode = mode;
|
||
|
||
// Update button states
|
||
const gridBtn = document.getElementById('gridViewBtn');
|
||
const listBtn = document.getElementById('listViewBtn');
|
||
|
||
if (mode === 'grid') {
|
||
gridBtn.classList.remove('bg-pm-input');
|
||
gridBtn.classList.add('bg-pm-accent');
|
||
listBtn.classList.remove('bg-pm-accent');
|
||
listBtn.classList.add('bg-pm-input');
|
||
} else {
|
||
listBtn.classList.remove('bg-pm-input');
|
||
listBtn.classList.add('bg-pm-accent');
|
||
gridBtn.classList.remove('bg-pm-accent');
|
||
gridBtn.classList.add('bg-pm-input');
|
||
}
|
||
|
||
this.renderGallery();
|
||
}
|
||
|
||
renderGallery({ from = 0 } = {}) {
|
||
if (from === 0) this.destroyViewer();
|
||
if (this.images.length === 0) {
|
||
this.showError('No images found in the output folder');
|
||
return;
|
||
}
|
||
|
||
document.getElementById('loadingState').classList.add('hidden');
|
||
document.getElementById('errorState').classList.add('hidden');
|
||
|
||
if (this.viewMode === 'grid') {
|
||
this.renderGridView({ from });
|
||
} else {
|
||
this.renderListView({ from });
|
||
}
|
||
|
||
this.addFallbackClickHandlers();
|
||
}
|
||
|
||
renderGridView({ from = 0 } = {}) {
|
||
const grid = document.getElementById('galleryGrid');
|
||
const list = document.getElementById('galleryList');
|
||
const showInfo = this.getSettings().showImageInfo;
|
||
|
||
list.classList.add('hidden');
|
||
grid.classList.remove('hidden');
|
||
|
||
const html = this.images.slice(from).map((image, offset) => {
|
||
const index = from + offset;
|
||
// Use thumbnail for display if available, fallback to original
|
||
const displayUrl = image.thumbnail_url || image.url;
|
||
const hasThumb = !!image.thumbnail_url;
|
||
const isVideo = image.is_video || false;
|
||
const mediaType = image.media_type || 'image';
|
||
|
||
return `
|
||
<div data-image-index="${index}" class="image-item bg-pm-surface rounded-pm-md overflow-hidden border border-pm hover:border-pm cursor-pointer group">
|
||
<div class="aspect-square bg-pm-primary overflow-hidden relative">
|
||
<img src="${escapeHtml(displayUrl)}"
|
||
alt="${escapeHtml(image.filename)}"
|
||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||
loading="lazy"
|
||
data-original="${escapeHtml(image.url)}"
|
||
data-thumbnail="${escapeHtml(image.thumbnail_url || '')}"
|
||
data-caption="${escapeHtml(this.formatImageCaption(image))}"
|
||
data-media-type="${escapeHtml(mediaType)}"
|
||
data-is-video="${isVideo}"
|
||
>
|
||
<!-- Fallback for failed thumbnails -->
|
||
<div data-media-fallback class="hidden absolute inset-0 bg-pm-surface text-pm-secondary flex items-center justify-center">
|
||
<div class="text-center">
|
||
<div class="text-lg mb-1">${isVideo ? '🎬' : '🖼️'}</div>
|
||
<div class="text-xs">Failed to load</div>
|
||
</div>
|
||
</div>
|
||
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-opacity duration-300 flex items-center justify-center">
|
||
${isVideo ? `
|
||
<div class="w-12 h-12 bg-black/60 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||
<svg class="w-6 h-6 text-pm ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M8 5v14l11-7z"/>
|
||
</svg>
|
||
</div>
|
||
` : `
|
||
<svg class="w-8 h-8 text-pm opacity-0 group-hover:opacity-100 transition-opacity duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||
</svg>
|
||
`}
|
||
</div>
|
||
${hasThumb ? '<div class="absolute top-2 right-2 w-3 h-3 bg-pm-success rounded-full" title="Thumbnail available"></div>' : ''}
|
||
${isVideo ? '<div class="absolute top-2 left-2 px-2 py-1 bg-pm-error text-pm text-xs rounded" title="Video file">VIDEO</div>' : ''}
|
||
</div>
|
||
<div class="p-2">
|
||
<div class="text-xs text-pm-secondary truncate" title="${escapeHtml(image.filename)}">${escapeHtml(image.filename)}</div>
|
||
${showInfo ? `<div class="text-xs text-pm-muted">${this.formatFileSize(image.size)}${hasThumb ? ' • Fast' : ''}${isVideo ? ' • Video' : ''}</div>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
if (from === 0) grid.innerHTML = html;
|
||
else grid.insertAdjacentHTML('beforeend', html);
|
||
}
|
||
|
||
renderListView({ from = 0 } = {}) {
|
||
const grid = document.getElementById('galleryGrid');
|
||
const list = document.getElementById('galleryList');
|
||
const showInfo = this.getSettings().showImageInfo;
|
||
|
||
grid.classList.add('hidden');
|
||
list.classList.remove('hidden');
|
||
|
||
const html = this.images.slice(from).map((image, offset) => {
|
||
const index = from + offset;
|
||
// Use thumbnail for display if available, fallback to original
|
||
const displayUrl = image.thumbnail_url || image.url;
|
||
const hasThumb = !!image.thumbnail_url;
|
||
const isVideo = image.is_video || false;
|
||
const mediaType = image.media_type || 'image';
|
||
|
||
return `
|
||
<div data-image-index="${index}" class="image-item bg-pm-surface rounded-pm-md border border-pm hover:border-pm cursor-pointer group flex items-center p-4">
|
||
<div class="w-16 h-16 bg-pm-primary rounded-pm-md overflow-hidden flex-shrink-0 mr-4 relative">
|
||
<img src="${escapeHtml(displayUrl)}"
|
||
alt="${escapeHtml(image.filename)}"
|
||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||
loading="lazy"
|
||
data-original="${escapeHtml(image.url)}"
|
||
data-thumbnail="${escapeHtml(image.thumbnail_url || '')}"
|
||
data-caption="${escapeHtml(this.formatImageCaption(image))}"
|
||
data-media-type="${escapeHtml(mediaType)}"
|
||
data-is-video="${isVideo}">
|
||
${hasThumb ? '<div class="absolute top-1 right-1 w-2 h-2 bg-pm-success rounded-full" title="Thumbnail available"></div>' : ''}
|
||
${isVideo ? '<div class="absolute top-1 left-1 w-4 h-3 bg-pm-error text-pm text-xs flex items-center justify-center rounded" title="Video">▶</div>' : ''}
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<div class="text-sm font-medium text-pm truncate">${escapeHtml(image.filename)}${isVideo ? ' 🎬' : ''}</div>
|
||
${showInfo ? `<div class="text-xs text-pm-secondary">${this.formatFileSize(image.size)} • ${new Date(image.modified_time * 1000).toLocaleDateString()}${hasThumb ? ' • Fast' : ''}${isVideo ? ' • Video' : ''}</div>` : ''}
|
||
<div class="text-xs text-pm-muted truncate">${escapeHtml(image.relative_path)}</div>
|
||
</div>
|
||
<div class="flex-shrink-0 ml-4">
|
||
${isVideo ? `
|
||
<svg class="w-5 h-5 text-pm-error group-hover:text-pm-error transition-colors" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M8 5v14l11-7z"/>
|
||
</svg>
|
||
` : `
|
||
<svg class="w-5 h-5 text-pm-secondary group-hover:text-pm transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||
</svg>
|
||
`}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
if (from === 0) list.innerHTML = html;
|
||
else list.insertAdjacentHTML('beforeend', html);
|
||
}
|
||
|
||
destroyViewer({ keepSlideshow = false } = {}) {
|
||
if (!keepSlideshow) this.stopViewerSlideshow();
|
||
if (this.viewer) {
|
||
this.viewer.clearEnforceFocus();
|
||
this.viewer.destroy();
|
||
}
|
||
this.viewer = null;
|
||
document.getElementById('metadata-sidebar')?.remove();
|
||
}
|
||
|
||
openViewer(index, { keepSlideshow = false } = {}) {
|
||
if (!Number.isInteger(index) || index < 0 || index >= this.images.length) return;
|
||
this.destroyViewer({ keepSlideshow });
|
||
const size = 100;
|
||
const start = Math.max(0, Math.min(index - size / 2, this.images.length - size));
|
||
const container = document.createElement('div');
|
||
container.innerHTML = this.images.slice(start, start + size).map(image =>
|
||
`<img src="${escapeHtml(image.thumbnail_url || image.url)}"
|
||
data-original="${escapeHtml(image.url)}"
|
||
alt="${escapeHtml(image.filename)}">`
|
||
).join('');
|
||
|
||
this.viewer = new Viewer(container, {
|
||
inline: false,
|
||
initialViewIndex: index - start,
|
||
button: true,
|
||
backdrop: true,
|
||
navbar: true,
|
||
title: true,
|
||
// IMPORTANT: Use original images for viewing, not thumbnails
|
||
url: 'data-original', // Tell ViewerJS to use data-original attribute
|
||
toolbar: {
|
||
zoomIn: 1,
|
||
zoomOut: 1,
|
||
oneToOne: 1,
|
||
reset: 1,
|
||
prev: 1,
|
||
play: {
|
||
show: 1,
|
||
size: 'large',
|
||
},
|
||
next: 1,
|
||
rotateLeft: 1,
|
||
rotateRight: 1,
|
||
flipHorizontal: 1,
|
||
flipVertical: 1,
|
||
},
|
||
className: '',
|
||
title: [1, (image, imageData) => `${imageData.alt} (${this.images.length} images) - Original Image`],
|
||
viewed: (event) => {
|
||
// Add metadata sidebar when ViewerJS opens
|
||
// Use the original image URL from data-original attribute
|
||
const originalImg = event.detail.originalImage;
|
||
this.addMetadataSidebar(originalImg);
|
||
},
|
||
play: (event) => {
|
||
event.preventDefault();
|
||
if (this.slideshowPlaying) this.stopViewerSlideshow();
|
||
else {
|
||
this.slideshowPlaying = true;
|
||
if (this.viewer.options.fullscreen) this.viewer.requestFullscreen();
|
||
this.scheduleViewerSlide();
|
||
}
|
||
},
|
||
hide: () => {
|
||
this.viewer?.clearEnforceFocus();
|
||
this.stopViewerSlideshow();
|
||
const sidebar = document.getElementById('metadata-sidebar');
|
||
if (sidebar) {
|
||
sidebar.remove();
|
||
}
|
||
}
|
||
});
|
||
|
||
const viewer = this.viewer;
|
||
const navigate = (direction, loop = false) => {
|
||
if (viewer !== this.viewer || viewer.hiding || viewer.played) return viewer;
|
||
let target = start + viewer.index + direction;
|
||
if (loop) target = (target + this.images.length) % this.images.length;
|
||
else target = Math.max(0, Math.min(target, this.images.length - 1));
|
||
if (target >= start && target < start + viewer.length) {
|
||
viewer.view(target - start);
|
||
} else {
|
||
this.openViewer(target, { keepSlideshow: true });
|
||
}
|
||
return this.viewer;
|
||
};
|
||
viewer.prev = (loop) => navigate(-1, loop);
|
||
viewer.next = (loop) => navigate(1, loop);
|
||
viewer.show();
|
||
}
|
||
|
||
stopViewerSlideshow() {
|
||
clearTimeout(this.slideshowTimer);
|
||
this.slideshowTimer = null;
|
||
if (this.slideshowPlaying) this.viewer?.exitFullscreen();
|
||
this.slideshowPlaying = false;
|
||
}
|
||
|
||
scheduleViewerSlide() {
|
||
// Native Viewer.js slideshows preload every original and loop within the filmstrip.
|
||
this.slideshowTimer = setTimeout(() => {
|
||
this.slideshowTimer = null;
|
||
if (!this.slideshowPlaying || !this.viewer) return;
|
||
this.viewer.next(true);
|
||
if (this.slideshowPlaying) this.scheduleViewerSlide();
|
||
}, 5000);
|
||
}
|
||
|
||
addMetadataSidebar(originalImage) {
|
||
const viewerContainer = document.querySelector('.viewer-container');
|
||
if (!viewerContainer) return;
|
||
const existing = document.getElementById('metadata-sidebar');
|
||
if (existing) {
|
||
this.loadImageMetadata(originalImage.dataset.original || originalImage.src,
|
||
existing.querySelector('#metadata-content'));
|
||
return;
|
||
}
|
||
|
||
const sidebar = document.createElement('div');
|
||
sidebar.id = 'metadata-sidebar';
|
||
sidebar.className = 'metadata-sidebar';
|
||
sidebar.innerHTML = `
|
||
<!-- Collapse Button -->
|
||
<div class="metadata-collapse-btn" onclick="this.closest('.metadata-sidebar').classList.toggle('collapsed')">
|
||
<svg class="w-4 h-4 text-pm-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||
</svg>
|
||
</div>
|
||
|
||
<!-- Header -->
|
||
<div class="flex items-center justify-between p-4 border-b border-pm">
|
||
<div class="flex items-center gap-2">
|
||
<svg class="w-5 h-5 text-pm-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||
</svg>
|
||
<h1 class="text-sm font-semibold text-pm">Generation data</h1>
|
||
</div>
|
||
<button class="text-xs text-pm-accent hover:text-pm-accent transition-colors metadata-copy-all">
|
||
📋 COPY ALL
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Scrollable Content -->
|
||
<div id="metadata-content" class="flex-1 overflow-y-auto p-4 space-y-3">
|
||
<!-- Loading state -->
|
||
<div class="text-center text-pm-muted py-8">
|
||
<div class="w-8 h-8 border-2 border-pm-accent/30 border-t-pm-accent rounded-full animate-spin mx-auto mb-2"></div>
|
||
<p class="text-sm">Loading metadata...</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(sidebar);
|
||
|
||
// Apply default collapsed state from settings
|
||
if (this.getSettings().sidebarCollapsedByDefault) {
|
||
sidebar.classList.add('collapsed');
|
||
}
|
||
|
||
// Load metadata for the current image (ALWAYS use original for metadata)
|
||
const originalImageUrl = originalImage.dataset.original || originalImage.src;
|
||
this.loadImageMetadata(originalImageUrl, sidebar.querySelector('#metadata-content'));
|
||
}
|
||
|
||
addFallbackClickHandlers() {
|
||
for (const id of ['galleryGrid', 'galleryList']) {
|
||
const container = document.getElementById(id);
|
||
if (container.dataset.mediaClicksBound) continue;
|
||
container.dataset.mediaClicksBound = '1';
|
||
container.addEventListener('click', (event) => {
|
||
const item = event.target.closest('.image-item');
|
||
if (!item) return;
|
||
const img = item.querySelector('img[data-original]');
|
||
if (!img) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const index = Number(item.dataset.imageIndex);
|
||
const { original, caption, isVideo } = img.dataset;
|
||
if (isVideo === 'true') {
|
||
this.showVideoModal(original, caption, index);
|
||
} else {
|
||
try {
|
||
this.openViewer(index);
|
||
} catch (error) {
|
||
console.warn('ViewerJS failed, using fallback:', error);
|
||
this.destroyViewer();
|
||
this.showSimpleImageModal(original, caption);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
async showSimpleImageModal(imageUrl, caption) {
|
||
// Create a modal that matches the screenshot design
|
||
const modal = document.createElement('div');
|
||
modal.className = 'fixed inset-0 bg-black z-50 flex';
|
||
modal.id = 'image-viewer-modal';
|
||
modal.innerHTML = `
|
||
<!-- Image area with navigation -->
|
||
<div class="flex-1 flex items-center justify-center relative image-viewer-area">
|
||
<!-- Navigation arrows -->
|
||
<button class="absolute left-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-black/50 hover:bg-black/70 rounded-full flex items-center justify-center text-pm text-xl z-10" id="prevImageBtn">
|
||
‹
|
||
</button>
|
||
<button class="absolute right-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-black/50 hover:bg-black/70 rounded-full flex items-center justify-center text-pm text-xl z-10" id="nextImageBtn">
|
||
›
|
||
</button>
|
||
|
||
<!-- Close button -->
|
||
<button class="absolute top-4 right-4 w-10 h-10 bg-black/50 hover:bg-black/70 rounded-full flex items-center justify-center text-pm text-xl z-10" onclick="this.parentElement.parentElement.remove()">
|
||
×
|
||
</button>
|
||
|
||
<!-- Main image -->
|
||
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(caption)}" class="max-w-full max-h-full object-contain" id="modalImage">
|
||
</div>
|
||
|
||
<!-- Metadata sidebar (matching the screenshot) -->
|
||
<div class="metadata-sidebar">
|
||
<!-- Collapse Button -->
|
||
<div class="metadata-collapse-btn" onclick="this.closest('.metadata-sidebar').classList.toggle('collapsed')">
|
||
<svg class="w-4 h-4 text-pm-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||
</svg>
|
||
</div>
|
||
|
||
<!-- Header -->
|
||
<div class="flex items-center justify-between p-4 border-b border-pm">
|
||
<div class="flex items-center gap-2">
|
||
<svg class="w-5 h-5 text-pm-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||
</svg>
|
||
<h1 class="text-sm font-semibold text-pm">Generation data</h1>
|
||
</div>
|
||
<button class="text-xs text-pm-accent hover:text-pm-accent transition-colors metadata-copy-all">
|
||
📋 COPY ALL
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Scrollable Content -->
|
||
<div id="metadata-content" class="flex-1 overflow-y-auto p-4 space-y-3">
|
||
<!-- Loading state -->
|
||
<div class="text-center text-pm-muted py-8">
|
||
<div class="w-8 h-8 border-2 border-pm-accent/30 border-t-pm-accent rounded-full animate-spin mx-auto mb-2"></div>
|
||
<p class="text-sm">Loading metadata...</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Close on click outside image area
|
||
modal.addEventListener('click', (e) => {
|
||
if (e.target === modal || e.target.id === 'modalImage') {
|
||
modal.remove();
|
||
}
|
||
});
|
||
|
||
// Close on Escape key; the listener is dropped once the modal leaves the
|
||
// document, whichever way it was closed
|
||
const escapeHandler = (e) => {
|
||
if (e.key === 'Escape') modal.remove();
|
||
};
|
||
document.addEventListener('keydown', escapeHandler);
|
||
const closeObserver = new MutationObserver(() => {
|
||
if (modal.isConnected) return;
|
||
document.removeEventListener('keydown', escapeHandler);
|
||
closeObserver.disconnect();
|
||
});
|
||
closeObserver.observe(document.body, { childList: true });
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
// Apply default collapsed state from settings
|
||
const sidebarInModal = modal.querySelector('.metadata-sidebar');
|
||
if (sidebarInModal && this.getSettings().sidebarCollapsedByDefault) {
|
||
sidebarInModal.classList.add('collapsed');
|
||
}
|
||
|
||
// Add navigation functionality
|
||
this.setupImageNavigation(modal, imageUrl);
|
||
|
||
// Load and display metadata
|
||
await this.loadImageMetadata(imageUrl, modal.querySelector('#metadata-content'));
|
||
}
|
||
|
||
async showVideoModal(videoUrl, caption, videoIndex) {
|
||
const settings = this.getSettings();
|
||
|
||
// Create a modal for video viewing
|
||
const modal = document.createElement('div');
|
||
modal.className = 'fixed inset-0 bg-black z-50 flex';
|
||
modal.innerHTML = `
|
||
<!-- Video area with navigation -->
|
||
<div class="flex-1 flex items-center justify-center relative">
|
||
<!-- Navigation arrows -->
|
||
<button class="absolute left-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-black/50 hover:bg-black/70 rounded-full flex items-center justify-center text-pm text-xl z-10" id="prevVideoBtn">
|
||
‹
|
||
</button>
|
||
<button class="absolute right-4 top-1/2 transform -translate-y-1/2 w-10 h-10 bg-black/50 hover:bg-black/70 rounded-full flex items-center justify-center text-pm text-xl z-10" id="nextVideoBtn">
|
||
›
|
||
</button>
|
||
|
||
<!-- Close button -->
|
||
<button class="absolute top-4 right-4 w-10 h-10 bg-black/50 hover:bg-black/70 rounded-full flex items-center justify-center text-pm text-xl z-10" onclick="this.parentElement.parentElement.remove()">
|
||
×
|
||
</button>
|
||
|
||
<!-- Video controls -->
|
||
<div class="absolute bottom-4 right-4 flex space-x-2 z-10">
|
||
<button id="videoMuteBtn" class="px-3 py-2 bg-black/50 hover:bg-black/70 rounded text-pm text-sm">
|
||
🔊
|
||
</button>
|
||
<button id="videoLoopBtn" class="px-3 py-2 bg-black/50 hover:bg-black/70 rounded text-pm text-sm">
|
||
🔁
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Main video -->
|
||
<video id="modalVideo"
|
||
src="${escapeHtml(videoUrl)}"
|
||
class="max-w-full max-h-full object-contain"
|
||
controls
|
||
${settings.videoAutoplay ? 'autoplay' : ''}
|
||
${settings.videoMute ? 'muted' : ''}
|
||
${settings.videoLoop ? 'loop' : ''}>
|
||
Your browser does not support the video tag.
|
||
</video>
|
||
</div>
|
||
|
||
<!-- Metadata sidebar (videos have limited metadata) -->
|
||
<div class="metadata-sidebar">
|
||
<!-- Collapse Button -->
|
||
<div class="metadata-collapse-btn" onclick="this.closest('.metadata-sidebar').classList.toggle('collapsed')">
|
||
<svg class="w-4 h-4 text-pm-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||
</svg>
|
||
</div>
|
||
|
||
<!-- Header -->
|
||
<div class="flex items-center justify-between p-4 border-b border-pm">
|
||
<div class="flex items-center gap-2">
|
||
<svg class="w-5 h-5 text-pm-error" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M8 5v14l11-7z"/>
|
||
</svg>
|
||
<h1 class="text-sm font-semibold text-pm">Video Info</h1>
|
||
</div>
|
||
<button class="text-xs text-pm-accent hover:text-pm-accent transition-colors" data-action="copy-url" data-url="${escapeHtml(videoUrl)}" data-label="📋 COPY URL">
|
||
📋 COPY URL
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Video info content -->
|
||
<div id="video-info-content" class="flex-1 overflow-y-auto p-4 space-y-3">
|
||
<!-- Video details will be loaded here -->
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Close on click outside video area
|
||
modal.addEventListener('click', (e) => {
|
||
if (e.target === modal) {
|
||
modal.remove();
|
||
}
|
||
});
|
||
|
||
// Close on Escape key; the listener is dropped once the modal leaves the
|
||
// document, whichever way it was closed
|
||
const escapeHandler = (e) => {
|
||
if (e.key === 'Escape') modal.remove();
|
||
};
|
||
document.addEventListener('keydown', escapeHandler);
|
||
const closeObserver = new MutationObserver(() => {
|
||
if (modal.isConnected) return;
|
||
document.removeEventListener('keydown', escapeHandler);
|
||
closeObserver.disconnect();
|
||
});
|
||
closeObserver.observe(document.body, { childList: true });
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
// Apply default collapsed state from settings
|
||
const sidebarInVideoModal = modal.querySelector('.metadata-sidebar');
|
||
if (sidebarInVideoModal && this.getSettings().sidebarCollapsedByDefault) {
|
||
sidebarInVideoModal.classList.add('collapsed');
|
||
}
|
||
|
||
// Set up video controls
|
||
this.setupVideoControls(modal, settings);
|
||
|
||
// Add navigation functionality
|
||
this.setupVideoNavigation(modal, videoUrl, videoIndex);
|
||
|
||
// Load and display video info
|
||
await this.loadVideoInfo(videoUrl, modal.querySelector('#video-info-content'));
|
||
}
|
||
|
||
setupVideoControls(modal, settings) {
|
||
const video = modal.querySelector('#modalVideo');
|
||
const muteBtn = modal.querySelector('#videoMuteBtn');
|
||
const loopBtn = modal.querySelector('#videoLoopBtn');
|
||
|
||
// Mute/unmute toggle
|
||
muteBtn.addEventListener('click', () => {
|
||
video.muted = !video.muted;
|
||
muteBtn.textContent = video.muted ? '🔇' : '🔊';
|
||
|
||
// Save setting
|
||
const newSettings = this.getSettings();
|
||
newSettings.videoMute = video.muted;
|
||
this.saveSettings(newSettings);
|
||
});
|
||
|
||
// Loop toggle
|
||
loopBtn.addEventListener('click', () => {
|
||
video.loop = !video.loop;
|
||
loopBtn.style.opacity = video.loop ? '1' : '0.5';
|
||
|
||
// Save setting
|
||
const newSettings = this.getSettings();
|
||
newSettings.videoLoop = video.loop;
|
||
this.saveSettings(newSettings);
|
||
});
|
||
|
||
// Set initial states
|
||
muteBtn.textContent = video.muted ? '🔇' : '🔊';
|
||
loopBtn.style.opacity = video.loop ? '1' : '0.5';
|
||
}
|
||
|
||
setupVideoNavigation(modal, currentVideoUrl, currentIndex) {
|
||
const prevBtn = modal.querySelector('#prevVideoBtn');
|
||
const nextBtn = modal.querySelector('#nextVideoBtn');
|
||
|
||
// Get all video items from current gallery
|
||
const container = this.viewMode === 'grid' ? document.getElementById('galleryGrid') : document.getElementById('galleryList');
|
||
const videoItems = Array.from(container.querySelectorAll('.image-item img[data-is-video="true"]'));
|
||
|
||
if (videoItems.length <= 1) {
|
||
prevBtn.style.display = 'none';
|
||
nextBtn.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
const currentVideoIndex = videoItems.findIndex(img => img.dataset.original === currentVideoUrl);
|
||
|
||
prevBtn.addEventListener('click', () => {
|
||
const prevIndex = currentVideoIndex > 0 ? currentVideoIndex - 1 : videoItems.length - 1;
|
||
const prevVideo = videoItems[prevIndex];
|
||
if (prevVideo) {
|
||
modal.remove();
|
||
this.showVideoModal(prevVideo.dataset.original, prevVideo.dataset.caption, prevIndex);
|
||
}
|
||
});
|
||
|
||
nextBtn.addEventListener('click', () => {
|
||
const nextIndex = currentVideoIndex < videoItems.length - 1 ? currentVideoIndex + 1 : 0;
|
||
const nextVideo = videoItems[nextIndex];
|
||
if (nextVideo) {
|
||
modal.remove();
|
||
this.showVideoModal(nextVideo.dataset.original, nextVideo.dataset.caption, nextIndex);
|
||
}
|
||
});
|
||
|
||
// Add keyboard navigation
|
||
const keyHandler = (e) => {
|
||
if (e.key === 'ArrowLeft') {
|
||
e.preventDefault();
|
||
prevBtn.click();
|
||
} else if (e.key === 'ArrowRight') {
|
||
e.preventDefault();
|
||
nextBtn.click();
|
||
}
|
||
};
|
||
|
||
document.addEventListener('keydown', keyHandler);
|
||
|
||
// Clean up listener when modal is removed
|
||
const observer = new MutationObserver((mutations) => {
|
||
mutations.forEach((mutation) => {
|
||
if (mutation.type === 'childList') {
|
||
mutation.removedNodes.forEach((node) => {
|
||
if (node === modal) {
|
||
document.removeEventListener('keydown', keyHandler);
|
||
observer.disconnect();
|
||
}
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
observer.observe(document.body, { childList: true });
|
||
}
|
||
|
||
async loadVideoInfo(videoUrl, container) {
|
||
try {
|
||
// Extract basic video information
|
||
const fileName = videoUrl.split('/').pop();
|
||
const fileExt = fileName.split('.').pop().toUpperCase();
|
||
|
||
container.innerHTML = `
|
||
<!-- File Info -->
|
||
<div class="space-y-2">
|
||
<h2 class="text-sm font-medium text-pm-secondary">File Info</h2>
|
||
<div class="text-xs text-pm-secondary font-mono bg-pm-surface p-2 rounded break-all cursor-pointer hover:bg-pm-hover"
|
||
data-action="copy-url" data-url="${escapeHtml(videoUrl)}" data-label="${escapeHtml(fileName)}"
|
||
title="Click to copy path">
|
||
${escapeHtml(fileName)}
|
||
</div>
|
||
<div class="text-xs text-pm-muted">
|
||
Type: ${escapeHtml(fileExt)} Video
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Video Properties -->
|
||
<div class="space-y-2" id="video-properties">
|
||
<h2 class="text-sm font-medium text-pm-secondary">Video Properties</h2>
|
||
<div class="text-xs text-pm-secondary">
|
||
Loading video information...
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Video Settings -->
|
||
<div class="space-y-3">
|
||
<h2 class="text-sm font-medium text-pm-secondary">Playback Settings</h2>
|
||
<div class="space-y-2 text-xs">
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-pm-secondary">Autoplay:</span>
|
||
<span class="text-pm">${this.getSettings().videoAutoplay ? 'Enabled' : 'Disabled'}</span>
|
||
</div>
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-pm-secondary">Muted:</span>
|
||
<span class="text-pm">${this.getSettings().videoMute ? 'Yes' : 'No'}</span>
|
||
</div>
|
||
<div class="flex justify-between items-center">
|
||
<span class="text-pm-secondary">Loop:</span>
|
||
<span class="text-pm">${this.getSettings().videoLoop ? 'Enabled' : 'Disabled'}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Note about metadata -->
|
||
<div class="bg-pm-accent-tint border border-pm-accent rounded-pm-md p-3">
|
||
<h4 class="text-pm-accent font-medium mb-1 text-sm">ℹ️ Video Note</h4>
|
||
<p class="text-pm-secondary text-xs">
|
||
Videos don't contain the same metadata as PNG images. ComfyUI workflow data is only embedded in PNG outputs.
|
||
</p>
|
||
</div>
|
||
`;
|
||
|
||
// Try to get video metadata from the video element
|
||
const video = document.querySelector('#modalVideo');
|
||
if (video) {
|
||
video.addEventListener('loadedmetadata', () => {
|
||
const propertiesDiv = container.querySelector('#video-properties');
|
||
if (propertiesDiv) {
|
||
const duration = video.duration;
|
||
const width = video.videoWidth;
|
||
const height = video.videoHeight;
|
||
|
||
propertiesDiv.innerHTML = `
|
||
<h2 class="text-sm font-medium text-pm-secondary">Video Properties</h2>
|
||
<div class="space-y-1 text-xs">
|
||
<div class="flex justify-between">
|
||
<span class="text-pm-secondary">Duration:</span>
|
||
<span class="text-pm">${this.formatDuration(duration)}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-pm-secondary">Resolution:</span>
|
||
<span class="text-pm">${width} × ${height}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-pm-secondary">Aspect Ratio:</span>
|
||
<span class="text-pm">${(width/height).toFixed(2)}:1</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Error loading video info:', error);
|
||
container.innerHTML = `
|
||
<div class="bg-pm-error-tint border border-pm-error rounded-pm-md p-4">
|
||
<h4 class="text-pm-error font-medium mb-2">❌ Video Info Error</h4>
|
||
<p class="text-pm-secondary text-sm">${escapeHtml(error.message)}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
formatDuration(seconds) {
|
||
if (isNaN(seconds) || seconds === 0) return '0:00';
|
||
|
||
const hours = Math.floor(seconds / 3600);
|
||
const minutes = Math.floor((seconds % 3600) / 60);
|
||
const secs = Math.floor(seconds % 60);
|
||
|
||
if (hours > 0) {
|
||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||
} else {
|
||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||
}
|
||
}
|
||
|
||
async loadImageMetadata(imageUrl, container) {
|
||
try {
|
||
// First try to get linked prompt from database
|
||
let databasePrompt = null;
|
||
try {
|
||
// Extract relative path from the URL for the API call
|
||
const urlParts = imageUrl.split('/prompt_manager/images/serve/');
|
||
if (urlParts.length > 1) {
|
||
const relativePath = urlParts[1];
|
||
const response = await ApiClient.fetchUrl(`/prompt_manager/images/prompt/${encodeURIComponent(relativePath)}`);
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (data.success && data.prompt) {
|
||
databasePrompt = data.prompt;
|
||
}
|
||
}
|
||
}
|
||
} catch (dbError) {
|
||
// No database prompt found, will fall back to metadata extraction
|
||
}
|
||
|
||
// If we have a database prompt, use it; otherwise extract from metadata
|
||
let metadata;
|
||
if (databasePrompt) {
|
||
// Create metadata object from database prompt
|
||
metadata = {
|
||
positivePrompt: databasePrompt.text,
|
||
negativePrompt: 'No negative prompt found', // Database doesn't separate positive/negative
|
||
checkpoint: 'Unknown',
|
||
steps: 'Unknown',
|
||
cfgScale: 'Unknown',
|
||
sampler: 'Unknown',
|
||
seed: 'Unknown',
|
||
workflow: databasePrompt.workflow_data,
|
||
imagePath: imageUrl,
|
||
source: 'database', // Mark this as coming from database
|
||
promptId: databasePrompt.prompt_id,
|
||
category: databasePrompt.category,
|
||
tags: databasePrompt.tags,
|
||
rating: databasePrompt.rating,
|
||
notes: databasePrompt.notes
|
||
};
|
||
|
||
// Still try to extract technical parameters from PNG metadata if available
|
||
try {
|
||
const pngMetadata = await this.extractImageMetadata(imageUrl);
|
||
if (pngMetadata) {
|
||
metadata.checkpoint = pngMetadata.checkpoint;
|
||
metadata.steps = pngMetadata.steps;
|
||
metadata.cfgScale = pngMetadata.cfgScale;
|
||
metadata.sampler = pngMetadata.sampler;
|
||
metadata.seed = pngMetadata.seed;
|
||
// Use negative prompt from PNG if available
|
||
if (pngMetadata.negativePrompt && pngMetadata.negativePrompt !== 'No negative prompt found') {
|
||
metadata.negativePrompt = pngMetadata.negativePrompt;
|
||
}
|
||
}
|
||
} catch (pngError) {
|
||
// Could not extract PNG metadata, using database-only data
|
||
}
|
||
} else {
|
||
// Fall back to PNG metadata extraction
|
||
metadata = await this.extractImageMetadata(imageUrl);
|
||
if (metadata) {
|
||
metadata.source = 'png'; // Mark this as coming from PNG
|
||
}
|
||
}
|
||
|
||
this.displayMetadata(metadata, container);
|
||
} catch (error) {
|
||
console.error('Error loading metadata:', error);
|
||
container.innerHTML = `
|
||
<div class="bg-pm-error-tint border border-pm-error rounded-pm-md p-4">
|
||
<h4 class="text-pm-error font-medium mb-2">❌ Metadata Error</h4>
|
||
<p class="text-pm-secondary text-sm">${escapeHtml(error.message)}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
displayMetadata(metadata, container) {
|
||
if (!metadata) {
|
||
container.innerHTML = `
|
||
<div class="text-center text-pm-muted py-8">
|
||
<svg class="w-16 h-16 mx-auto mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||
</svg>
|
||
<p class="text-sm">No metadata found in this image</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const html = `
|
||
<!-- File Path -->
|
||
<div class="space-y-2">
|
||
<h2 class="text-sm font-medium text-pm-secondary">File Path</h2>
|
||
<div class="text-xs text-pm-secondary font-mono bg-pm-surface p-2 rounded break-all cursor-pointer hover:bg-pm-hover" data-copy-path title="Click to copy">
|
||
${escapeHtml(metadata.imagePath || 'Unknown')}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Resources used -->
|
||
<div class="space-y-3">
|
||
<h2 class="text-sm font-medium text-pm-secondary">Resources used</h2>
|
||
<div class="space-y-2 text-xs">
|
||
<div class="flex justify-between">
|
||
<span class="text-pm-secondary">Model:</span>
|
||
<span class="text-pm">${escapeHtml(metadata.checkpoint || 'Unknown')}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Prompt Section -->
|
||
<div class="space-y-3">
|
||
<div class="flex items-center justify-between">
|
||
<h2 class="text-sm font-medium text-pm-secondary">Prompt</h2>
|
||
<button class="text-xs px-2 py-1 bg-pm-warning hover:bg-pm-warning text-pm rounded transition-colors" data-copy-type="positive">
|
||
COPY PROMPT
|
||
</button>
|
||
</div>
|
||
<div class="text-xs text-pm bg-pm-surface p-3 rounded max-h-32 overflow-y-auto break-words leading-relaxed">
|
||
${this.formatPromptText(metadata.positivePrompt)}
|
||
<div class="mt-2 pt-2 border-t border-pm">
|
||
<button class="text-pm-accent hover:text-pm-accent underline text-xs" data-show-type="positive">
|
||
Show more
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Negative Prompt -->
|
||
<div class="space-y-3">
|
||
<h2 class="text-sm font-medium text-pm-secondary">Negative prompt</h2>
|
||
<div class="text-xs text-pm bg-pm-surface p-3 rounded max-h-32 overflow-y-auto break-words leading-relaxed">
|
||
${this.formatPromptText(metadata.negativePrompt)}
|
||
<div class="mt-2 pt-2 border-t border-pm">
|
||
<button class="text-pm-accent hover:text-pm-accent underline text-xs" data-show-type="negative">
|
||
Show more
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Other metadata -->
|
||
<div class="space-y-3">
|
||
<h2 class="text-sm font-medium text-pm-secondary">Other metadata</h2>
|
||
<div class="space-y-2 text-xs">
|
||
<div class="flex justify-between">
|
||
<span class="text-pm-secondary">CFG SCALE:</span>
|
||
<span class="text-pm px-2 py-1 bg-pm-surface rounded text-xs">${escapeHtml(metadata.cfgScale || 'Unknown')}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-pm-secondary">STEPS:</span>
|
||
<span class="text-pm px-2 py-1 bg-pm-surface rounded text-xs">${escapeHtml(metadata.steps || 'Unknown')}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-pm-secondary">SAMPLER:</span>
|
||
<span class="text-pm px-2 py-1 bg-pm-surface rounded text-xs">${escapeHtml(metadata.sampler || 'Unknown')}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-pm-secondary">SEED:</span>
|
||
<span class="text-pm font-mono px-2 py-1 bg-pm-surface rounded text-xs">${escapeHtml(metadata.seed || 'Unknown')}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ComfyUI Workflow -->
|
||
${metadata.workflow ? `
|
||
<div class="space-y-3">
|
||
<h2 class="text-sm font-medium text-pm-secondary">ComfyUI Workflow</h2>
|
||
<div class="space-y-2">
|
||
<button class="text-xs text-pm-accent hover:text-pm-accent underline flex items-center gap-1" data-action="show-workflow">
|
||
<span>View Raw Workflow JSON</span>
|
||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||
</svg>
|
||
</button>
|
||
<button class="text-xs text-pm-accent hover:text-pm-accent underline flex items-center gap-1" data-action="download-workflow">
|
||
<span>Download Workflow</span>
|
||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
`;
|
||
|
||
container.innerHTML = html;
|
||
|
||
// Store metadata for actions
|
||
this.currentMetadata = metadata;
|
||
|
||
// Add click handlers
|
||
this.setupMetadataActions(container);
|
||
}
|
||
|
||
setupImageNavigation(modal, currentImageUrl) {
|
||
// Find current image index
|
||
this.currentImageIndex = this.images.findIndex(img => img.url === currentImageUrl);
|
||
|
||
const prevBtn = modal.querySelector('#prevImageBtn');
|
||
const nextBtn = modal.querySelector('#nextImageBtn');
|
||
const modalImage = modal.querySelector('#modalImage');
|
||
|
||
const updateImage = async (newIndex) => {
|
||
if (newIndex >= 0 && newIndex < this.images.length) {
|
||
this.currentImageIndex = newIndex;
|
||
const newImage = this.images[newIndex];
|
||
// ALWAYS use original URL for viewing and metadata
|
||
modalImage.src = newImage.url; // This is always the original URL
|
||
modalImage.alt = this.formatImageCaption(newImage);
|
||
|
||
// Update metadata using original image
|
||
const metadataContainer = modal.querySelector('#metadata-content');
|
||
metadataContainer.innerHTML = `
|
||
<div class="text-center text-pm-muted py-8">
|
||
<div class="w-8 h-8 border-2 border-pm-accent/30 border-t-pm-accent rounded-full animate-spin mx-auto mb-2"></div>
|
||
<p class="text-sm">Loading metadata from original image...</p>
|
||
</div>
|
||
`;
|
||
// Load metadata from original image (which has embedded workflow data)
|
||
await this.loadImageMetadata(newImage.url, metadataContainer);
|
||
}
|
||
|
||
// Update button states
|
||
prevBtn.style.opacity = newIndex > 0 ? '1' : '0.5';
|
||
nextBtn.style.opacity = newIndex < this.images.length - 1 ? '1' : '0.5';
|
||
};
|
||
|
||
prevBtn.addEventListener('click', () => {
|
||
if (this.currentImageIndex > 0) {
|
||
updateImage(this.currentImageIndex - 1);
|
||
}
|
||
});
|
||
|
||
nextBtn.addEventListener('click', () => {
|
||
if (this.currentImageIndex < this.images.length - 1) {
|
||
updateImage(this.currentImageIndex + 1);
|
||
}
|
||
});
|
||
|
||
// Keyboard navigation
|
||
const keyHandler = (e) => {
|
||
if (e.key === 'ArrowLeft' && this.currentImageIndex > 0) {
|
||
updateImage(this.currentImageIndex - 1);
|
||
} else if (e.key === 'ArrowRight' && this.currentImageIndex < this.images.length - 1) {
|
||
updateImage(this.currentImageIndex + 1);
|
||
}
|
||
};
|
||
document.addEventListener('keydown', keyHandler);
|
||
|
||
// 'remove' is not a DOM event; watch for the modal leaving the document instead
|
||
const navObserver = new MutationObserver(() => {
|
||
if (modal.isConnected) return;
|
||
document.removeEventListener('keydown', keyHandler);
|
||
navObserver.disconnect();
|
||
});
|
||
navObserver.observe(document.body, { childList: true });
|
||
|
||
// Initial button state
|
||
updateImage(this.currentImageIndex);
|
||
}
|
||
|
||
setupMetadataActions(container) {
|
||
// Copy prompt buttons
|
||
const copyButtons = container.querySelectorAll('[data-copy-type]');
|
||
copyButtons.forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
const type = btn.getAttribute('data-copy-type');
|
||
if (this.currentMetadata) {
|
||
const text = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt;
|
||
this.copyToClipboard(text);
|
||
}
|
||
});
|
||
});
|
||
|
||
// File path copy
|
||
const pathEl = container.querySelector('[data-copy-path]');
|
||
if (pathEl) {
|
||
pathEl.addEventListener('click', () => {
|
||
if (this.currentMetadata) {
|
||
this.copyToClipboard(this.currentMetadata.imagePath);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Show more buttons
|
||
const showMoreBtns = container.querySelectorAll('[data-show-type]');
|
||
showMoreBtns.forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
const type = btn.getAttribute('data-show-type');
|
||
this.showFullPrompt(type);
|
||
});
|
||
});
|
||
|
||
// Workflow action buttons
|
||
const actionBtns = container.querySelectorAll('[data-action]');
|
||
actionBtns.forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
const action = btn.getAttribute('data-action');
|
||
if (action === 'show-workflow') {
|
||
this.showWorkflowData();
|
||
} else if (action === 'download-workflow') {
|
||
this.downloadWorkflowJSON();
|
||
}
|
||
});
|
||
});
|
||
|
||
// Copy all button
|
||
const copyAllBtn = container.closest('.metadata-sidebar').querySelector('.metadata-copy-all');
|
||
if (copyAllBtn) {
|
||
copyAllBtn.addEventListener('click', () => {
|
||
this.copyAllMetadata();
|
||
});
|
||
}
|
||
}
|
||
|
||
showFullPrompt(type) {
|
||
if (this.currentMetadata) {
|
||
const prompt = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt;
|
||
const safeType = escapeHtml(type.charAt(0).toUpperCase() + type.slice(1));
|
||
const newWindow = window.open('', '_blank');
|
||
const doc = newWindow.document;
|
||
doc.open();
|
||
doc.write('<!DOCTYPE html><html><head><title>' + safeType + ' Prompt</title></head><body></body></html>');
|
||
doc.close();
|
||
doc.body.style.cssText = 'background:#111;color:#fff;font-family:monospace;padding:20px;';
|
||
const h2 = doc.createElement('h2');
|
||
h2.textContent = type.charAt(0).toUpperCase() + type.slice(1) + ' Prompt';
|
||
doc.body.appendChild(h2);
|
||
const pre = doc.createElement('pre');
|
||
pre.style.cssText = 'background:#222;padding:15px;border-radius:5px;white-space:pre-wrap;line-height:1.5;';
|
||
pre.textContent = prompt;
|
||
doc.body.appendChild(pre);
|
||
const btn = doc.createElement('button');
|
||
btn.textContent = 'Copy to Clipboard';
|
||
btn.style.cssText = 'margin-top:20px;padding:10px 20px;background:#444;color:#fff;border:none;border-radius:5px;cursor:pointer;';
|
||
btn.addEventListener('click', () => { navigator.clipboard.writeText(pre.textContent).then(() => alert('Copied!')); });
|
||
doc.body.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
showWorkflowData() {
|
||
if (this.currentMetadata && this.currentMetadata.workflow) {
|
||
const newWindow = window.open('', '_blank');
|
||
if (!newWindow) {
|
||
this.showNotification('Popup blocked: allow popups to view the workflow', 'warning');
|
||
return;
|
||
}
|
||
newWindow.document.write(ImageHelpers.workflowDocumentHtml(this.currentMetadata.workflow));
|
||
newWindow.document.close();
|
||
}
|
||
}
|
||
|
||
async copyToClipboard(text) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
this.showNotification('Copied to clipboard!', 'success');
|
||
} catch (err) {
|
||
console.error('Copy failed:', err);
|
||
this.showNotification('Copy failed', 'error');
|
||
}
|
||
}
|
||
|
||
copyAllMetadata() {
|
||
if (!this.currentMetadata) return;
|
||
|
||
const allData = `Checkpoint: ${this.currentMetadata.checkpoint || 'Unknown'}
|
||
Positive Prompt: ${this.currentMetadata.positivePrompt}
|
||
Negative Prompt: ${this.currentMetadata.negativePrompt}
|
||
Steps: ${this.currentMetadata.steps || 'Unknown'}
|
||
CFG Scale: ${this.currentMetadata.cfgScale || 'Unknown'}
|
||
Sampler: ${this.currentMetadata.sampler || 'Unknown'}
|
||
Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||
|
||
this.copyToClipboard(allData);
|
||
}
|
||
|
||
downloadWorkflowJSON() {
|
||
if (this.currentMetadata && this.currentMetadata.workflow) {
|
||
const dataStr = JSON.stringify(this.currentMetadata.workflow, null, 2);
|
||
const dataBlob = new Blob([dataStr], {type: 'application/json'});
|
||
const url = URL.createObjectURL(dataBlob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = 'comfyui_workflow.json';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
this.showNotification('Workflow downloaded!', 'success');
|
||
}
|
||
}
|
||
|
||
formatPromptText(text) {
|
||
if (!text || text === 'No prompt found' || text === 'No negative prompt found') {
|
||
return `<span class="text-pm-muted italic">${text || 'No prompt found'}</span>`;
|
||
}
|
||
return escapeHtml(text);
|
||
}
|
||
|
||
unescapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.innerHTML = text;
|
||
return div.textContent;
|
||
}
|
||
|
||
formatImageCaption(image) {
|
||
const date = new Date(image.modified_time * 1000);
|
||
return `${image.filename} | ${this.formatFileSize(image.size)} | ${date.toLocaleString()}`;
|
||
}
|
||
|
||
async extractImageMetadata(imageUrl) {
|
||
try {
|
||
// Fetch the image as an array buffer
|
||
const response = await ApiClient.fetchUrl(imageUrl);
|
||
const arrayBuffer = await response.arrayBuffer();
|
||
|
||
// Parse PNG metadata
|
||
const metadata = await this.parsePNGMetadata(arrayBuffer);
|
||
const comfyData = this.extractComfyUIData(metadata);
|
||
|
||
return this.parseWorkflowData(comfyData, imageUrl);
|
||
} catch (error) {
|
||
console.error('Error extracting metadata:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
parsePNGMetadata(arrayBuffer) {
|
||
return PngMetadata.parsePngTextChunks(arrayBuffer);
|
||
}
|
||
|
||
extractComfyUIData(metadata) {
|
||
let workflowData = null;
|
||
let promptData = null;
|
||
|
||
// Common ComfyUI metadata field names
|
||
const workflowFields = ['workflow', 'Workflow', 'comfy', 'ComfyUI'];
|
||
const promptFields = ['prompt', 'Prompt', 'parameters', 'Parameters'];
|
||
|
||
for (const field of workflowFields) {
|
||
if (metadata[field]) {
|
||
try {
|
||
// Clean NaN values from JSON string before parsing
|
||
let cleanedJson = metadata[field];
|
||
cleanedJson = cleanedJson.replace(/:\s*NaN\b/g, ': null');
|
||
cleanedJson = cleanedJson.replace(/\bNaN\b/g, 'null');
|
||
|
||
workflowData = JSON.parse(cleanedJson);
|
||
break;
|
||
} catch (e) {
|
||
console.warn('Failed to parse workflow field:', field);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const field of promptFields) {
|
||
if (metadata[field]) {
|
||
try {
|
||
// Clean NaN values from JSON string before parsing
|
||
let cleanedJson = metadata[field];
|
||
cleanedJson = cleanedJson.replace(/:\s*NaN\b/g, ': null');
|
||
cleanedJson = cleanedJson.replace(/\bNaN\b/g, 'null');
|
||
|
||
promptData = JSON.parse(cleanedJson);
|
||
break;
|
||
} catch (e) {
|
||
console.warn('Failed to parse prompt field:', field);
|
||
}
|
||
}
|
||
}
|
||
|
||
return { workflow: workflowData, prompt: promptData };
|
||
}
|
||
|
||
parseWorkflowData(comfyData, imagePath) {
|
||
// Trace prompts, model and sampler settings through the node graph (#75)
|
||
const {
|
||
checkpoint, positivePrompt, negativePrompt, steps, cfgScale, sampler, seed,
|
||
} = window.ComfyMetadata.extractGenerationParams(comfyData);
|
||
|
||
return {
|
||
checkpoint,
|
||
positivePrompt,
|
||
negativePrompt,
|
||
steps,
|
||
cfgScale,
|
||
sampler,
|
||
seed,
|
||
workflow: comfyData.workflow,
|
||
imagePath
|
||
};
|
||
}
|
||
|
||
// Settings functionality
|
||
showSettings() {
|
||
this.loadSettingsFromStorage();
|
||
document.getElementById('settingsModal').classList.remove('hidden');
|
||
document.getElementById('settingsModal').classList.add('flex');
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
hideSettings() {
|
||
document.getElementById('settingsModal').classList.add('hidden');
|
||
document.getElementById('settingsModal').classList.remove('flex');
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
loadSettingsFromStorage() {
|
||
const settings = this.getSettings();
|
||
|
||
// Load checkbox settings
|
||
document.getElementById('lazyLoadingToggle').checked = settings.lazyLoading;
|
||
document.getElementById('autoLoadMetadataToggle').checked = settings.autoLoadMetadata;
|
||
document.getElementById('cacheMetadataToggle').checked = settings.cacheMetadata;
|
||
document.getElementById('showFilePathsToggle').checked = settings.showFilePaths;
|
||
document.getElementById('sidebarCollapsedByDefaultToggle').checked = settings.sidebarCollapsedByDefault;
|
||
document.getElementById('infiniteScrollToggle').checked = settings.infiniteScroll;
|
||
document.getElementById('showImageInfoToggle').checked = settings.showImageInfo;
|
||
document.getElementById('debugModeToggle').checked = settings.debugMode;
|
||
document.getElementById('checkThumbnailsAtStartup').checked = settings.checkThumbnailsAtStartup;
|
||
|
||
// Load video settings
|
||
document.getElementById('videoAutoplayToggle').checked = settings.videoAutoplay;
|
||
document.getElementById('videoMuteToggle').checked = settings.videoMute;
|
||
document.getElementById('videoLoopToggle').checked = settings.videoLoop;
|
||
|
||
// Load select settings
|
||
document.getElementById('imageQualitySelect').value = settings.imageQuality;
|
||
document.getElementById('defaultViewModeSelect').value = settings.defaultViewMode;
|
||
document.getElementById('defaultLimitSelect').value = settings.defaultLimit;
|
||
document.getElementById('gridColumnsSelect').value = settings.gridColumns;
|
||
|
||
// Load input settings
|
||
document.getElementById('apiTimeoutInput').value = settings.apiTimeout;
|
||
|
||
// Update thumbnail status
|
||
document.getElementById('thumbnailStatus').textContent = settings.thumbnailsGenerated ? 'Generated' : 'Not generated';
|
||
}
|
||
|
||
getSettings() {
|
||
let stored = null;
|
||
try {
|
||
const raw = localStorage.getItem('gallerySettings');
|
||
if (raw) stored = JSON.parse(raw);
|
||
} catch (e) {
|
||
console.warn('Failed to load settings from localStorage:', e);
|
||
}
|
||
return GallerySettings.normalizeGallerySettings(stored);
|
||
}
|
||
|
||
saveSettings() {
|
||
const settings = {
|
||
lazyLoading: document.getElementById('lazyLoadingToggle').checked,
|
||
imageQuality: document.getElementById('imageQualitySelect').value,
|
||
defaultViewMode: document.getElementById('defaultViewModeSelect').value,
|
||
defaultLimit: parseInt(document.getElementById('defaultLimitSelect').value),
|
||
gridColumns: parseInt(document.getElementById('gridColumnsSelect').value),
|
||
showImageInfo: document.getElementById('showImageInfoToggle').checked,
|
||
autoLoadMetadata: document.getElementById('autoLoadMetadataToggle').checked,
|
||
cacheMetadata: document.getElementById('cacheMetadataToggle').checked,
|
||
showFilePaths: document.getElementById('showFilePathsToggle').checked,
|
||
sidebarCollapsedByDefault: document.getElementById('sidebarCollapsedByDefaultToggle').checked,
|
||
infiniteScroll: document.getElementById('infiniteScrollToggle').checked,
|
||
debugMode: document.getElementById('debugModeToggle').checked,
|
||
apiTimeout: parseInt(document.getElementById('apiTimeoutInput').value),
|
||
thumbnailsGenerated: this.getSettings().thumbnailsGenerated, // Preserve this
|
||
checkThumbnailsAtStartup: document.getElementById('checkThumbnailsAtStartup').checked,
|
||
// Video settings
|
||
videoAutoplay: document.getElementById('videoAutoplayToggle').checked,
|
||
videoMute: document.getElementById('videoMuteToggle').checked,
|
||
videoLoop: document.getElementById('videoLoopToggle').checked
|
||
};
|
||
|
||
try {
|
||
localStorage.setItem('gallerySettings', JSON.stringify(settings));
|
||
this.showNotification('Settings saved successfully!', 'success');
|
||
this.hideSettings();
|
||
|
||
// Apply some settings immediately
|
||
this.applySettings(settings);
|
||
} catch (e) {
|
||
console.error('Failed to save settings:', e);
|
||
this.showNotification('Failed to save settings', 'error');
|
||
}
|
||
}
|
||
|
||
resetSettings() {
|
||
if (confirm('Reset all settings to defaults? This cannot be undone.')) {
|
||
localStorage.removeItem('gallerySettings');
|
||
this.loadSettingsFromStorage();
|
||
this.showNotification('Settings reset to defaults', 'success');
|
||
}
|
||
}
|
||
|
||
applySettings(settings) {
|
||
// Runs at startup and after Save, so every display setting takes effect
|
||
// without a reload. Settings that change what is rendered re-render.
|
||
const loaded = this.images.length > 0;
|
||
this.updateGridColumns(settings.gridColumns);
|
||
|
||
const limitChanged = this.limit !== settings.defaultLimit;
|
||
this.limit = settings.defaultLimit;
|
||
const scrollChanged = this.infiniteScroll !== settings.infiniteScroll;
|
||
this.infiniteScroll = settings.infiniteScroll;
|
||
const limitSelector = document.getElementById('limitSelector');
|
||
if (limitSelector) limitSelector.value = String(settings.defaultLimit);
|
||
|
||
if (this.viewMode !== settings.defaultViewMode) {
|
||
this.setViewMode(settings.defaultViewMode);
|
||
} else if (loaded && !limitChanged) {
|
||
this.renderGallery(); // e.g. "Show Media Info" toggled
|
||
}
|
||
if (loaded && (limitChanged || scrollChanged)) this.loadImages(1);
|
||
}
|
||
|
||
updateGridColumns(columns) {
|
||
const gridContainer = document.getElementById('galleryGrid');
|
||
if (!gridContainer) return;
|
||
// The Tailwind column classes are compiled for a fixed set of values and a
|
||
// wider breakpoint overrides them; an explicit template wins on desktop widths.
|
||
gridContainer.style.removeProperty('grid-template-columns'); // undo a thumb-size override
|
||
gridContainer.style.setProperty('--gallery-columns', GallerySettings.gridColumnsStyle(columns));
|
||
gridContainer.classList.add('gallery-columns-custom');
|
||
}
|
||
|
||
async generateThumbnails() {
|
||
const btn = document.getElementById('generateThumbnailsBtn');
|
||
const status = document.getElementById('thumbnailStatus');
|
||
const progressContainer = document.getElementById('thumbnailProgress');
|
||
const progressBar = document.getElementById('progressBar');
|
||
const progressText = document.getElementById('progressText');
|
||
const progressPercent = document.getElementById('progressPercent');
|
||
const progressDetails = document.getElementById('progressDetails');
|
||
const progressETA = document.getElementById('progressETA');
|
||
|
||
btn.disabled = true;
|
||
btn.textContent = 'Generating...';
|
||
status.textContent = 'Initializing thumbnail generation...';
|
||
progressContainer.classList.remove('hidden');
|
||
|
||
// Initialize progress display
|
||
progressBar.style.width = '0%';
|
||
progressPercent.textContent = 'Starting...';
|
||
progressText.textContent = 'Connecting to server...';
|
||
progressDetails.textContent = 'Preparing to scan output directory...';
|
||
progressETA.textContent = '';
|
||
|
||
try {
|
||
// Start thumbnail generation with progress updates
|
||
const result = await this.generateThumbnailsWithProgress({
|
||
quality: document.getElementById('imageQualitySelect').value
|
||
});
|
||
|
||
// Update settings on success
|
||
const settings = this.getSettings();
|
||
settings.thumbnailsGenerated = true;
|
||
localStorage.setItem('gallerySettings', JSON.stringify(settings));
|
||
|
||
status.textContent = 'Generation completed';
|
||
progressText.textContent = 'Completed!';
|
||
progressDetails.textContent = `Generated ${result.count} new, skipped ${result.skipped || 0} existing (${result.total_images} total) in ${result.elapsed_time}s`;
|
||
|
||
this.notifyThumbnailsDone(result);
|
||
|
||
// Hide progress after a delay
|
||
setTimeout(() => {
|
||
progressContainer.classList.add('hidden');
|
||
}, 3000);
|
||
|
||
} catch (error) {
|
||
console.error('Thumbnail generation error:', error);
|
||
console.error('Error details:', error.stack);
|
||
status.textContent = 'Generation failed';
|
||
progressText.textContent = 'Failed';
|
||
progressDetails.textContent = error.message;
|
||
progressBar.style.width = '0%';
|
||
progressBar.classList.add('bg-pm-error');
|
||
progressPercent.textContent = 'Error';
|
||
|
||
this.showNotification('Failed to generate thumbnails: ' + error.message, 'error');
|
||
|
||
// Hide progress after a delay
|
||
setTimeout(() => {
|
||
progressContainer.classList.add('hidden');
|
||
progressBar.classList.remove('bg-pm-error');
|
||
}, 5000);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Generate';
|
||
}
|
||
}
|
||
|
||
async generateThumbnailsWithProgress(options) {
|
||
const progressBar = document.getElementById('progressBar');
|
||
const progressText = document.getElementById('progressText');
|
||
const progressPercent = document.getElementById('progressPercent');
|
||
const progressDetails = document.getElementById('progressDetails');
|
||
const progressETA = document.getElementById('progressETA');
|
||
const cancelBtn = document.getElementById('cancelThumbnailsBtn');
|
||
|
||
return new Promise((resolve, reject) => {
|
||
// Show cancel button
|
||
cancelBtn.classList.remove('hidden');
|
||
|
||
|
||
// Set up Server-Sent Events
|
||
// The progress route is POST (it generates files), so read it with SseStream like the autotag streams
|
||
const eventSource = SseStream.connect(`/prompt_manager/images/generate-thumbnails/progress?quality=${encodeURIComponent(options.quality)}`, { method: 'POST' });
|
||
|
||
// Log connection
|
||
|
||
let cancelled = false;
|
||
let resultData = null;
|
||
|
||
// Handle cancellation
|
||
const cancelHandler = () => {
|
||
cancelled = true;
|
||
eventSource.close();
|
||
cancelBtn.classList.add('hidden');
|
||
reject(new Error('Thumbnail generation cancelled by user'));
|
||
};
|
||
cancelBtn.addEventListener('click', cancelHandler);
|
||
|
||
// Handle scanning/status events
|
||
eventSource.addEventListener('status', (event) => {
|
||
if (cancelled) return;
|
||
const data = JSON.parse(event.data);
|
||
progressText.textContent = data.message || 'Processing...';
|
||
if (data.phase === 'scanning') {
|
||
progressDetails.textContent = 'Scanning output folder for images and videos...';
|
||
progressBar.style.width = '0%';
|
||
progressPercent.textContent = 'Scanning...';
|
||
} else {
|
||
progressDetails.textContent = 'Preparing thumbnail generation...';
|
||
}
|
||
|
||
// Log status updates
|
||
if (this.getSettings().debugMode) {
|
||
}
|
||
});
|
||
|
||
// Handle start event
|
||
eventSource.addEventListener('start', (event) => {
|
||
if (cancelled) return;
|
||
const data = JSON.parse(event.data);
|
||
// Show detailed file type breakdown if available
|
||
const fileBreakdown = (data.image_count !== undefined && data.video_count !== undefined)
|
||
? ` (${data.image_count} images, ${data.video_count} videos)`
|
||
: '';
|
||
progressText.textContent = `Starting to process ${data.total_images} media files${fileBreakdown}...`;
|
||
progressDetails.textContent = data.message || `Found ${data.total_images} images and videos to process`;
|
||
progressBar.style.width = '0%';
|
||
progressPercent.textContent = '0%';
|
||
progressETA.textContent = 'Calculating...';
|
||
|
||
// Log start event
|
||
if (this.getSettings().debugMode) {
|
||
}
|
||
});
|
||
|
||
// Handle progress updates
|
||
eventSource.addEventListener('progress', (event) => {
|
||
if (cancelled) return;
|
||
const data = JSON.parse(event.data);
|
||
|
||
// Update progress bar
|
||
progressBar.style.width = `${data.percentage}%`;
|
||
progressPercent.textContent = `${data.percentage}%`;
|
||
|
||
// Update text with current file being processed and action
|
||
const fileInfo = data.current_file ? ` - ${data.current_file}` : '';
|
||
const actionText = data.action === 'skipped' ? ' [Skipping]' : data.action === 'generating' ? ' [Generating]' : '';
|
||
progressText.textContent = `Processing ${data.file_type || 'file'}... (${data.processed}/${data.total_images})${fileInfo}${actionText}`;
|
||
|
||
// More detailed progress information
|
||
const elapsedText = data.elapsed ? ` | Time: ${Math.floor(data.elapsed)}s` : '';
|
||
const failedText = data.error_count ? `, Failed: ${data.error_count}` : '';
|
||
progressDetails.textContent = `Generated: ${data.generated}, Skipped: ${data.skipped}${failedText} | Rate: ${data.rate} img/s${elapsedText}`;
|
||
|
||
// Update ETA
|
||
if (data.eta > 0 && data.eta < 3600) {
|
||
const etaText = data.eta > 60 ? `${Math.floor(data.eta / 60)}m ${Math.floor(data.eta % 60)}s` : `${Math.floor(data.eta)}s`;
|
||
progressETA.textContent = `ETA: ${etaText}`;
|
||
} else {
|
||
progressETA.textContent = '';
|
||
}
|
||
|
||
// Log to console for debugging if debug mode is enabled
|
||
if (this.getSettings().debugMode) {
|
||
}
|
||
});
|
||
|
||
// Handle completion
|
||
eventSource.addEventListener('complete', (event) => {
|
||
if (cancelled) return;
|
||
resultData = JSON.parse(event.data);
|
||
|
||
// Update final progress
|
||
progressBar.style.width = '100%';
|
||
progressPercent.textContent = '100%';
|
||
progressText.textContent = 'Completed!';
|
||
|
||
// Show detailed completion message
|
||
const errorCount = resultData.error_count || (resultData.errors ? resultData.errors.length : 0);
|
||
const errors = errorCount > 0 ? ` (${errorCount} errors)` : '';
|
||
const rateText = resultData.processing_rate ? ` at ${resultData.processing_rate} img/s` : '';
|
||
progressDetails.textContent = `Generated: ${resultData.count} new, Skipped: ${resultData.skipped} existing | Total: ${resultData.total_images} files | Time: ${resultData.elapsed_time}s${rateText}${errors}`;
|
||
|
||
// Show first error if any occurred
|
||
if (resultData.errors && resultData.errors.length > 0 && this.getSettings().debugMode) {
|
||
console.error('First thumbnail error:', resultData.errors[0]);
|
||
}
|
||
progressETA.textContent = 'Done';
|
||
|
||
// Log completion details
|
||
if (this.getSettings().debugMode) {
|
||
if (resultData.errors && resultData.errors.length > 0) {
|
||
console.warn('Thumbnail generation errors:', resultData.errors);
|
||
}
|
||
}
|
||
|
||
// Clean up
|
||
eventSource.close();
|
||
cancelBtn.removeEventListener('click', cancelHandler);
|
||
cancelBtn.classList.add('hidden');
|
||
|
||
resolve(resultData);
|
||
});
|
||
|
||
// Per-file failures: count them, keep listening
|
||
eventSource.addEventListener('file_error', (event) => {
|
||
if (cancelled) return;
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
console.warn('Thumbnail failed:', data.current_file || data.file || '', data.error || '');
|
||
} catch (e) {
|
||
console.warn('Thumbnail failed (unparseable event)');
|
||
}
|
||
});
|
||
|
||
// Handle errors
|
||
eventSource.addEventListener('error', (event) => {
|
||
if (cancelled) return;
|
||
const data = JSON.parse(event.data);
|
||
|
||
// Clean up
|
||
eventSource.close();
|
||
cancelBtn.removeEventListener('click', cancelHandler);
|
||
cancelBtn.classList.add('hidden');
|
||
|
||
reject(new Error(data.error || 'Thumbnail generation failed'));
|
||
});
|
||
|
||
// Handle EventSource errors
|
||
eventSource.onerror = (error) => {
|
||
if (cancelled) return;
|
||
console.error('EventSource error:', error);
|
||
console.error('EventSource readyState:', eventSource.readyState);
|
||
|
||
// Check if connection is closing normally
|
||
if (eventSource.readyState === eventSource.CLOSED) {
|
||
} else {
|
||
console.error('EventSource connection failed');
|
||
}
|
||
|
||
// Clean up
|
||
eventSource.close();
|
||
cancelBtn.removeEventListener('click', cancelHandler);
|
||
cancelBtn.classList.add('hidden');
|
||
|
||
// Only reject if we don't have result data
|
||
if (!resultData) {
|
||
reject(new Error('Connection to server lost during thumbnail generation'));
|
||
}
|
||
};
|
||
|
||
// Stream ended without a complete/error frame (close() does not trigger this)
|
||
eventSource.onclose = () => {
|
||
if (cancelled || resultData) return;
|
||
cancelBtn.removeEventListener('click', cancelHandler);
|
||
cancelBtn.classList.add('hidden');
|
||
reject(new Error('Connection to server closed before thumbnail generation finished'));
|
||
};
|
||
});
|
||
}
|
||
|
||
|
||
async clearCache() {
|
||
if (confirm('Clear PromptManager cache and thumbnails? This will NOT affect your original images.')) {
|
||
try {
|
||
// Clear only PromptManager-related browser caches (never touch user images)
|
||
if ('caches' in window) {
|
||
const cacheNames = await caches.keys();
|
||
// Only clear caches that are specifically ours
|
||
const ourCaches = cacheNames.filter(name =>
|
||
name.includes('prompt-manager') ||
|
||
name.includes('gallery') ||
|
||
name.includes('thumbnail')
|
||
);
|
||
await Promise.all(ourCaches.map(name => caches.delete(name)));
|
||
}
|
||
|
||
// Clear only our localStorage entries (never touch user data)
|
||
Object.keys(localStorage).forEach(key => {
|
||
if (key.startsWith('gallery_cache_') ||
|
||
key.startsWith('metadata_cache_') ||
|
||
key.startsWith('promptManager_') ||
|
||
key.startsWith('pm_')) {
|
||
localStorage.removeItem(key);
|
||
}
|
||
});
|
||
|
||
// Call backend to clear server-side thumbnails (only our generated thumbnails)
|
||
try {
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/images/clear-thumbnails', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
}
|
||
} catch (e) {
|
||
console.warn('Could not clear server thumbnails:', e);
|
||
// Not a critical error - continue with local cleanup
|
||
}
|
||
|
||
// Update thumbnail status
|
||
const settings = this.getSettings();
|
||
settings.thumbnailsGenerated = false;
|
||
localStorage.setItem('gallerySettings', JSON.stringify(settings));
|
||
document.getElementById('thumbnailStatus').textContent = 'Not generated';
|
||
|
||
this.showNotification('PromptManager cache cleared successfully! Original images untouched.', 'success');
|
||
} catch (error) {
|
||
console.error('Clear cache error:', error);
|
||
this.showNotification('Failed to clear cache', 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
async rescanFolder() {
|
||
const btn = document.getElementById('rescanFolderBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Scanning...';
|
||
|
||
try {
|
||
// The listing is cached on the server; ask it to rewalk the folders first
|
||
const result = await this.api.post('/prompt_manager/gallery/rescan', {});
|
||
this.hideSettings();
|
||
await Promise.all([this.loadFolderTree(), this.loadImages(1)]);
|
||
const where = result.roots === 1 ? 'the output folder' : `${result.roots} folders`;
|
||
this.showNotification(`Rescanned ${where}: ${result.total} files`, 'success');
|
||
} catch (error) {
|
||
console.error('Rescan error:', error);
|
||
this.showNotification('Failed to rescan folder', 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Rescan';
|
||
}
|
||
}
|
||
|
||
// ── Duplicate scan ──────────────────────────────────────────────
|
||
// The scan runs on the server (POST /scan_duplicates/stream starts or attaches).
|
||
// Closing the modal only hides it: a pill shows progress and reopens it, and a
|
||
// reload re-attaches to a scan that is still running.
|
||
scanDuplicates() {
|
||
if (this.dupScan.status === ScanProgress.RUNNING) {
|
||
this.openDuplicatesModal();
|
||
return;
|
||
}
|
||
this.dupResults = null;
|
||
this.dupScan = ScanProgress.reduceScanEvent(ScanProgress.initialState(), {
|
||
type: 'progress', progress: 0, status: 'Starting...', processed: 0, found: 0,
|
||
});
|
||
this.setDuplicateButton(true);
|
||
this.openDuplicatesModal();
|
||
this.attachDuplicateStream();
|
||
}
|
||
|
||
attachDuplicateStream() {
|
||
if (this.dupSource) this.dupSource.close();
|
||
const source = SseStream.connect('/prompt_manager/scan_duplicates/stream', { method: 'POST', body: '{}' });
|
||
this.dupSource = source;
|
||
source.onmessage = (e) => {
|
||
let data;
|
||
try {
|
||
data = JSON.parse(e.data);
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
this.applyDuplicateEvent(data);
|
||
};
|
||
source.onerror = () => {
|
||
if (this.dupSource !== source || this.dupScan.status !== ScanProgress.RUNNING) return;
|
||
setTimeout(() => this.resumeDuplicateScan(), 2000);
|
||
};
|
||
source.onclose = () => {
|
||
if (this.dupSource === source && this.dupScan.status === ScanProgress.RUNNING) this.resumeDuplicateScan();
|
||
};
|
||
}
|
||
|
||
async resumeDuplicateScan() {
|
||
let status;
|
||
try {
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/scan_duplicates/status');
|
||
if (!response.ok) return;
|
||
status = await response.json();
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
const state = ScanProgress.fromStatus(status);
|
||
if (state.status !== ScanProgress.RUNNING) {
|
||
if (this.dupScan.status === ScanProgress.RUNNING) {
|
||
// The scan we were following ended while we were away
|
||
const last = status.last_event;
|
||
const terminal = last && (last.type === 'complete' || last.type === 'error');
|
||
this.applyDuplicateEvent(terminal ? last : { type: 'error', message: 'the server no longer reports a running scan' });
|
||
}
|
||
return;
|
||
}
|
||
this.dupScan = state;
|
||
this.setDuplicateButton(true);
|
||
this.renderDuplicateProgress();
|
||
if (document.getElementById('duplicatesModal').classList.contains('hidden')) this.showDuplicatePill();
|
||
this.attachDuplicateStream();
|
||
}
|
||
|
||
applyDuplicateEvent(data) {
|
||
const next = ScanProgress.reduceScanEvent(this.dupScan, data);
|
||
if (next === this.dupScan) return;
|
||
this.dupScan = next;
|
||
this.renderDuplicateProgress();
|
||
if (next.status === ScanProgress.DONE) {
|
||
this.dupResults = Array.isArray(data.duplicates) ? data.duplicates : [];
|
||
this.setDuplicateButton(false);
|
||
const modalOpen = !document.getElementById('duplicatesModal').classList.contains('hidden');
|
||
if (modalOpen) {
|
||
this.hideDuplicatePill();
|
||
this.displayDuplicates(this.dupResults);
|
||
} else {
|
||
this.showDuplicatePill(`Duplicate scan done: ${this.dupResults.length} groups · click to view`);
|
||
}
|
||
this.showNotification(`Found ${this.dupResults.length} duplicate image groups`, 'info');
|
||
} else if (next.status === ScanProgress.FAILED) {
|
||
this.setDuplicateButton(false);
|
||
this.hideDuplicatePill();
|
||
this.hideDuplicatesModal();
|
||
this.showNotification(`Duplicate scan failed: ${next.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
renderDuplicateProgress() {
|
||
const state = this.dupScan;
|
||
const bar = document.getElementById('dupProgressBar');
|
||
const text = document.getElementById('dupProgressText');
|
||
if (bar) bar.style.width = `${state.progress}%`;
|
||
if (text) text.textContent = state.status === ScanProgress.RUNNING ? `${state.progress}% · ${state.processed} files hashed` : state.statusText;
|
||
const pill = document.getElementById('dupScanPillText');
|
||
if (pill && state.status === ScanProgress.RUNNING) pill.textContent = `Finding duplicates ${state.progress}% · ${state.processed} files`;
|
||
}
|
||
|
||
setDuplicateButton(running) {
|
||
const btn = document.getElementById('scanDuplicatesBtn');
|
||
if (!btn) return;
|
||
btn.disabled = running;
|
||
btn.textContent = running ? 'Scanning...' : 'Scan';
|
||
}
|
||
|
||
showDuplicatePill(label) {
|
||
const pill = document.getElementById('dupScanPill');
|
||
if (label) document.getElementById('dupScanPillText').textContent = label;
|
||
pill.classList.remove('hidden');
|
||
}
|
||
|
||
hideDuplicatePill() {
|
||
document.getElementById('dupScanPill').classList.add('hidden');
|
||
}
|
||
|
||
/** Show the modal in whatever state the scan is in: progress, or results. */
|
||
openDuplicatesModal() {
|
||
this.hideDuplicatePill();
|
||
document.getElementById('duplicatesModal').classList.remove('hidden');
|
||
document.getElementById('duplicatesModal').classList.add('flex');
|
||
document.body.style.overflow = 'hidden';
|
||
if (this.dupScan.status === ScanProgress.DONE && this.dupResults) {
|
||
this.displayDuplicates(this.dupResults);
|
||
return;
|
||
}
|
||
document.getElementById('duplicatesScanStatus').classList.remove('hidden');
|
||
document.getElementById('duplicatesContent').classList.add('hidden');
|
||
document.getElementById('duplicatesFooter').classList.add('hidden');
|
||
this.renderDuplicateProgress();
|
||
}
|
||
|
||
showDuplicatesModal() {
|
||
this.openDuplicatesModal();
|
||
}
|
||
|
||
hideDuplicatesModal() {
|
||
document.getElementById('duplicatesModal').classList.add('hidden');
|
||
document.getElementById('duplicatesModal').classList.remove('flex');
|
||
document.body.style.overflow = '';
|
||
// Minimize rather than abandon: the scan keeps running on the server
|
||
if (this.dupScan.status === ScanProgress.RUNNING) this.showDuplicatePill();
|
||
}
|
||
|
||
displayDuplicates(duplicates) {
|
||
const scanStatus = document.getElementById('duplicatesScanStatus');
|
||
const content = document.getElementById('duplicatesContent');
|
||
const footer = document.getElementById('duplicatesFooter');
|
||
const list = document.getElementById('duplicatesList');
|
||
const count = document.getElementById('duplicatesCount');
|
||
|
||
scanStatus.classList.add('hidden');
|
||
|
||
if (duplicates.length === 0) {
|
||
list.innerHTML = '<div class="text-center py-4 text-pm-secondary">No duplicate images found! 🎉</div>';
|
||
content.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
count.textContent = duplicates.length;
|
||
list.innerHTML = '';
|
||
|
||
duplicates.forEach((group, groupIndex) => {
|
||
const groupEl = document.createElement('div');
|
||
groupEl.className = 'bg-pm-surface rounded-pm-md p-4 border border-pm';
|
||
|
||
groupEl.innerHTML = `
|
||
<div class="mb-3 text-sm font-medium text-pm-secondary">
|
||
Duplicate Group ${groupIndex + 1} (${group.images.length} identical ${escapeHtml(group.images[0].media_type)}s)
|
||
</div>
|
||
<div class="mb-3 p-3 bg-pm-primary rounded text-sm text-pm-secondary">
|
||
<strong>Content Hash:</strong> ${escapeHtml(String(group.hash || '').substring(0, 16))}...
|
||
</div>
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||
${group.images.map((image, imageIndex) => `
|
||
<div class="flex items-start p-3 bg-pm-primary rounded border border-pm ${imageIndex === 0 ? 'border-pm-success' : ''}">
|
||
<div class="flex-shrink-0 mr-3">
|
||
${image.media_type === 'video' ? `
|
||
<div class="w-20 h-20 bg-pm-input rounded flex items-center justify-center relative">
|
||
${image.thumbnail_url ? `
|
||
<img src="${escapeHtml(ApiClient.resolveUrl(image.thumbnail_url))}" alt="Video thumbnail" class="w-full h-full object-cover rounded">
|
||
` : `
|
||
<svg class="w-8 h-8 text-pm-secondary" fill="currentColor" viewBox="0 0 20 20">
|
||
<path d="M2 6a2 2 0 012-2h6l2 2h6a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"/>
|
||
</svg>
|
||
`}
|
||
<div class="absolute bottom-0 right-0 bg-black/75 text-pm text-xs px-1 rounded">VIDEO</div>
|
||
</div>
|
||
` : `
|
||
<img src="${escapeHtml(ApiClient.resolveUrl(image.thumbnail_url || image.url))}" alt="${escapeHtml(image.filename)}"
|
||
class="w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80"
|
||
onclick="this.parentElement.parentElement.querySelector('.image-preview').click()">
|
||
`}
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<div class="text-sm text-pm-secondary mb-2">
|
||
<div class="font-medium truncate" title="${escapeHtml(image.filename)}">${escapeHtml(image.filename)}</div>
|
||
<div class="text-xs text-pm-secondary mt-1">
|
||
<span class="font-medium">Size:</span> ${this.formatFileSize(image.size)} •
|
||
<span class="font-medium">Modified:</span> ${new Date(image.modified_time * 1000).toLocaleDateString()}
|
||
</div>
|
||
<div class="text-xs text-pm-muted mt-1 truncate" title="${escapeHtml(image.relative_path)}">
|
||
${escapeHtml(image.relative_path)}
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center space-x-2">
|
||
<button class="image-preview text-xs bg-pm-accent hover:bg-pm-accent-hover text-pm px-2 py-1 rounded"
|
||
data-action="open-url" data-url="${escapeHtml(ApiClient.resolveUrl(image.url))}">
|
||
View Full
|
||
</button>
|
||
</div>
|
||
<div class="flex items-center">
|
||
${imageIndex === 0 ? `
|
||
<span class="px-3 py-1 bg-pm-success text-pm text-xs rounded">KEEP (Oldest)</span>
|
||
` : `
|
||
<label class="flex items-center">
|
||
<input type="checkbox" class="duplicate-checkbox" data-group="${groupIndex}" data-image-path="${escapeHtml(image.path)}"
|
||
class="w-4 h-4 text-pm-error bg-pm-input border-pm rounded focus:ring-pm-error">
|
||
<span class="ml-2 text-sm text-pm-error">Delete</span>
|
||
</label>
|
||
`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
|
||
list.appendChild(groupEl);
|
||
});
|
||
|
||
content.classList.remove('hidden');
|
||
footer.classList.remove('hidden');
|
||
}
|
||
|
||
selectAllDuplicates() {
|
||
const checkboxes = document.querySelectorAll('.duplicate-checkbox');
|
||
const selectBtn = document.getElementById('selectAllBtn');
|
||
|
||
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
|
||
|
||
checkboxes.forEach(cb => {
|
||
cb.checked = !allChecked;
|
||
});
|
||
|
||
selectBtn.textContent = allChecked ? 'Select All to Delete' : 'Deselect All';
|
||
}
|
||
|
||
async removeDuplicates() {
|
||
const checkboxes = document.querySelectorAll('.duplicate-checkbox:checked');
|
||
|
||
if (checkboxes.length === 0) {
|
||
this.showNotification('No duplicate images selected for removal', 'warning');
|
||
return;
|
||
}
|
||
|
||
const confirmation = confirm(`Are you sure you want to permanently delete ${checkboxes.length} duplicate image files? This action cannot be undone!`);
|
||
if (!confirmation) return;
|
||
|
||
const btn = document.getElementById('removeDuplicatesBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Deleting...';
|
||
|
||
try {
|
||
const imagePaths = Array.from(checkboxes).map(cb => cb.dataset.imagePath);
|
||
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/delete_duplicate_images', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ image_paths: imagePaths })
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
this.showNotification(`Successfully deleted ${imagePaths.length} duplicate image files`, 'success');
|
||
this.hideDuplicatesModal();
|
||
this.loadImages(); // Refresh the gallery
|
||
} else {
|
||
throw new Error(data.error || 'Failed to delete duplicate images');
|
||
}
|
||
} catch (error) {
|
||
console.error('Remove duplicates error:', error);
|
||
this.showNotification('Failed to delete duplicate images', 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Remove Selected';
|
||
}
|
||
}
|
||
|
||
formatFileSize(bytes) {
|
||
if (bytes === 0) return '0 Bytes';
|
||
const k = 1024;
|
||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||
}
|
||
|
||
changeLimit(newLimit) {
|
||
this.limit = newLimit;
|
||
this.loadImages(1);
|
||
}
|
||
|
||
updatePagination() {
|
||
const totalPages = Math.ceil(this.total / this.limit);
|
||
const hasPrev = this.currentPage > 1;
|
||
const hasNext = this.currentPage < totalPages;
|
||
|
||
document.getElementById('prevPageBtn').disabled = !hasPrev;
|
||
document.getElementById('nextPageBtn').disabled = !hasNext;
|
||
document.getElementById('pageInfo').textContent = `of ${totalPages}`;
|
||
|
||
const pageInput = document.getElementById('pageInput');
|
||
pageInput.value = this.currentPage;
|
||
pageInput.max = totalPages;
|
||
|
||
if (totalPages > 1 && !this.infiniteScroll) {
|
||
document.getElementById('paginationControls').classList.remove('hidden');
|
||
} else {
|
||
document.getElementById('paginationControls').classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
previousPage() {
|
||
if (this.currentPage > 1) this.loadImages(this.currentPage - 1);
|
||
}
|
||
|
||
nextPage() {
|
||
const totalPages = Math.ceil(this.total / this.limit);
|
||
if (this.currentPage < totalPages) this.loadImages(this.currentPage + 1);
|
||
}
|
||
|
||
goToPage(page) {
|
||
const totalPages = Math.ceil(this.total / this.limit);
|
||
if (isNaN(page) || page < 1 || page > totalPages) {
|
||
// Reset to current page if invalid input
|
||
document.getElementById('pageInput').value = this.currentPage;
|
||
return;
|
||
}
|
||
|
||
if (page !== this.currentPage) this.loadImages(page);
|
||
}
|
||
|
||
async checkThumbnailsAtStartup() {
|
||
// Wait a moment to ensure page is fully loaded
|
||
setTimeout(async () => {
|
||
const settings = this.getSettings();
|
||
|
||
// Only check if the feature is enabled
|
||
if (!settings.checkThumbnailsAtStartup) {
|
||
console.debug('Thumbnail startup check disabled in settings');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Check a sample of images to see if any are missing thumbnails
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/images/output?limit=50&offset=0');
|
||
const data = await response.json();
|
||
|
||
console.debug('Checking images for missing thumbnails:', data);
|
||
|
||
if (data.success && data.images && data.images.length > 0) {
|
||
// Count images missing thumbnails
|
||
let missingThumbnails = 0;
|
||
for (const image of data.images) {
|
||
if (!image.thumbnail_url) {
|
||
missingThumbnails++;
|
||
}
|
||
}
|
||
|
||
console.debug(`Found ${missingThumbnails} images without thumbnails in sample of ${data.images.length}`);
|
||
|
||
// If more than 10% of sampled images are missing thumbnails, show prompt
|
||
const missingPercentage = (missingThumbnails / data.images.length) * 100;
|
||
if (missingThumbnails > 0 && missingPercentage > 10) {
|
||
// Estimate total missing based on sample
|
||
const estimatedMissing = Math.round((missingThumbnails / data.images.length) * data.total);
|
||
console.debug(`Estimated ${estimatedMissing} total images missing thumbnails (${missingPercentage.toFixed(1)}% of sample)`);
|
||
this.showThumbnailGenerationPrompt(data.total, estimatedMissing);
|
||
} else if (missingThumbnails > 0) {
|
||
console.debug(`Only ${missingPercentage.toFixed(1)}% of images missing thumbnails, not showing prompt`);
|
||
} else {
|
||
console.debug('All sampled images have thumbnails');
|
||
}
|
||
} else {
|
||
console.debug('No images found or failed to fetch images');
|
||
}
|
||
} catch (error) {
|
||
console.debug('Could not check for missing thumbnails at startup:', error);
|
||
}
|
||
}, 1000); // Wait 1 second for page to settle
|
||
}
|
||
|
||
showThumbnailGenerationPrompt(imageCount, missingCount = imageCount) {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
|
||
modal.innerHTML = `
|
||
<div class="bg-pm-surface rounded-pm-md max-w-2xl w-full mx-4 border border-pm">
|
||
<div class="p-4">
|
||
<h3 class="text-sm font-semibold text-pm mb-3 flex items-center">
|
||
<span class="mr-2">🚀</span>
|
||
Generate Thumbnails?
|
||
</h3>
|
||
<p class="text-pm-secondary mb-4">
|
||
Found ${missingCount} images without thumbnails (${imageCount} total images). Would you like to generate thumbnails for faster loading?
|
||
</p>
|
||
<p class="text-sm text-pm-muted mb-3">
|
||
💡 Thumbnails speed up gallery loading significantly. You can change this setting in Gallery Settings.
|
||
</p>
|
||
<div class="flex space-x-3">
|
||
<button id="thumbnailPromptNo" class="flex-1 px-4 py-2 bg-pm-input hover:bg-pm-hover text-pm rounded-pm-md transition-colors">
|
||
Not Now
|
||
</button>
|
||
<button id="thumbnailPromptYes" class="flex-1 px-4 py-2 bg-pm-accent hover:bg-pm-accent-hover text-pm rounded-pm-md transition-colors">
|
||
Generate
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
// Handle button clicks
|
||
document.getElementById('thumbnailPromptNo').addEventListener('click', () => {
|
||
document.body.removeChild(modal);
|
||
});
|
||
|
||
document.getElementById('thumbnailPromptYes').addEventListener('click', () => {
|
||
// Don't remove modal yet - transform it to show progress
|
||
this.startThumbnailGenerationInModal(modal, imageCount, missingCount);
|
||
});
|
||
|
||
// Close on outside click
|
||
modal.addEventListener('click', (e) => {
|
||
if (e.target === modal) {
|
||
document.body.removeChild(modal);
|
||
}
|
||
});
|
||
}
|
||
|
||
async startThumbnailGenerationInModal(modal, imageCount, missingCount) {
|
||
// Transform the modal to show progress
|
||
const modalContent = modal.querySelector('.bg-pm-surface');
|
||
modalContent.innerHTML = `
|
||
<div class="p-4">
|
||
<h3 class="text-sm font-semibold text-pm mb-3 flex items-center">
|
||
<span class="mr-2">🚀</span>
|
||
Generating Thumbnails
|
||
</h3>
|
||
<p class="text-pm-secondary mb-4">
|
||
Processing ${missingCount} images out of ${imageCount} total...
|
||
</p>
|
||
|
||
<!-- Progress Section -->
|
||
<div class="mb-3">
|
||
<div class="flex items-center justify-between text-sm text-pm-secondary mb-2">
|
||
<span id="modalProgressText">Initializing...</span>
|
||
<span id="modalProgressPercent">0%</span>
|
||
</div>
|
||
<div class="w-full bg-pm-input rounded-full h-3">
|
||
<div id="modalProgressBar" class="bg-pm-accent h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||
</div>
|
||
<div class="flex items-center justify-between text-xs text-pm-muted mt-2">
|
||
<span id="modalProgressDetails">Starting thumbnail generation...</span>
|
||
<span id="modalProgressETA" class="text-pm-muted"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Status Messages -->
|
||
<div id="modalStatusMessages" class="text-sm text-pm-secondary mb-4 max-h-40 overflow-y-auto">
|
||
<div class="text-pm-accent">• Starting thumbnail generation...</div>
|
||
</div>
|
||
|
||
<!-- Buttons -->
|
||
<div class="flex space-x-3">
|
||
<button id="modalCancelBtn" class="flex-1 px-4 py-2 bg-pm-error hover:bg-pm-error text-pm rounded-pm-md transition-colors">
|
||
Cancel
|
||
</button>
|
||
<button id="modalCloseBtn" class="flex-1 px-4 py-2 bg-pm-input hover:bg-pm-hover text-pm rounded-pm-md transition-colors hidden">
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Setup progress tracking
|
||
let isCancelled = false;
|
||
const progressText = modal.querySelector('#modalProgressText');
|
||
const progressPercent = modal.querySelector('#modalProgressPercent');
|
||
const progressBar = modal.querySelector('#modalProgressBar');
|
||
const progressDetails = modal.querySelector('#modalProgressDetails');
|
||
const progressETA = modal.querySelector('#modalProgressETA');
|
||
const statusMessages = modal.querySelector('#modalStatusMessages');
|
||
const cancelBtn = modal.querySelector('#modalCancelBtn');
|
||
const closeBtn = modal.querySelector('#modalCloseBtn');
|
||
|
||
// Add status message helper
|
||
const addStatusMessage = (message, type = 'info') => {
|
||
const colors = {
|
||
'info': 'text-pm-secondary',
|
||
'success': 'text-pm-success',
|
||
'error': 'text-pm-error',
|
||
'warning': 'text-pm-warning'
|
||
};
|
||
const msgEl = document.createElement('div');
|
||
msgEl.className = colors[type] || colors.info;
|
||
msgEl.textContent = `• ${message}`;
|
||
statusMessages.appendChild(msgEl);
|
||
statusMessages.scrollTop = statusMessages.scrollHeight;
|
||
};
|
||
|
||
// Handle cancel
|
||
cancelBtn.addEventListener('click', () => {
|
||
isCancelled = true;
|
||
addStatusMessage('Cancellation requested...', 'warning');
|
||
cancelBtn.disabled = true;
|
||
cancelBtn.textContent = 'Cancelling...';
|
||
});
|
||
|
||
// Handle close (only appears when done)
|
||
closeBtn.addEventListener('click', () => {
|
||
document.body.removeChild(modal);
|
||
// Refresh the gallery to show new thumbnails
|
||
this.loadImages();
|
||
});
|
||
|
||
// Disable outside click while processing
|
||
modal.removeEventListener('click', modal.clickHandler);
|
||
|
||
try {
|
||
addStatusMessage('Connecting to thumbnail generation service...');
|
||
|
||
// Start thumbnail generation with progress monitoring
|
||
const thumbResult = await this.generateThumbnailsWithProgressInModal({
|
||
progressText,
|
||
progressPercent,
|
||
progressBar,
|
||
progressDetails,
|
||
progressETA,
|
||
addStatusMessage,
|
||
isCancelled: () => isCancelled
|
||
});
|
||
|
||
// Show completion
|
||
if (!isCancelled) {
|
||
progressText.textContent = 'Completed!';
|
||
progressPercent.textContent = '100%';
|
||
progressBar.style.width = '100%';
|
||
addStatusMessage('Thumbnail generation completed successfully!', 'success');
|
||
|
||
// Update settings
|
||
const settings = this.getSettings();
|
||
settings.thumbnailsGenerated = true;
|
||
localStorage.setItem('gallerySettings', JSON.stringify(settings));
|
||
|
||
// Show notification
|
||
this.notifyThumbnailsDone(thumbResult);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Thumbnail generation failed:', error);
|
||
addStatusMessage(`Generation failed: ${error.message}`, 'error');
|
||
progressBar.classList.add('bg-pm-error');
|
||
progressBar.classList.remove('bg-pm-accent');
|
||
} finally {
|
||
// Show close button and hide cancel
|
||
cancelBtn.classList.add('hidden');
|
||
closeBtn.classList.remove('hidden');
|
||
|
||
// Re-enable outside click to close
|
||
setTimeout(() => {
|
||
modal.addEventListener('click', (e) => {
|
||
if (e.target === modal) {
|
||
document.body.removeChild(modal);
|
||
// Refresh the gallery to show new thumbnails
|
||
this.loadImages();
|
||
}
|
||
});
|
||
}, 100);
|
||
}
|
||
} async generateThumbnailsWithProgressInModal(uiElements) {
|
||
const { progressText, progressPercent, progressBar, progressDetails, progressETA, addStatusMessage, isCancelled } = uiElements;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
addStatusMessage('Starting thumbnail generation...');
|
||
|
||
// Get quality setting
|
||
const quality = this.getSettings().imageQuality || 'medium';
|
||
|
||
|
||
// Set up Server-Sent Events (same as the settings modal)
|
||
// The progress route is POST (it generates files), so read it with SseStream like the autotag streams
|
||
const eventSource = SseStream.connect(`/prompt_manager/images/generate-thumbnails/progress?quality=${encodeURIComponent(quality)}`, { method: 'POST' });
|
||
|
||
let resultData = null;
|
||
|
||
// Handle scanning/status events
|
||
eventSource.addEventListener('status', (event) => {
|
||
if (isCancelled()) {
|
||
eventSource.close();
|
||
return;
|
||
}
|
||
const data = JSON.parse(event.data);
|
||
|
||
if (data.phase === 'scanning') {
|
||
progressText.textContent = 'Scanning for images...';
|
||
progressDetails.textContent = data.message || 'Scanning output folder for images and videos...';
|
||
addStatusMessage(data.message || 'Scanning output folder...', 'info');
|
||
} else if (data.phase === 'processing' && data.message.includes('Error')) {
|
||
addStatusMessage(data.message, 'error');
|
||
} else {
|
||
progressDetails.textContent = data.message || 'Preparing...';
|
||
}
|
||
});
|
||
|
||
// Handle start event
|
||
eventSource.addEventListener('start', (event) => {
|
||
if (isCancelled()) {
|
||
eventSource.close();
|
||
return;
|
||
}
|
||
const data = JSON.parse(event.data);
|
||
|
||
// Show detailed file type breakdown
|
||
const fileBreakdown = (data.image_count !== undefined && data.video_count !== undefined)
|
||
? ` (${data.image_count} images, ${data.video_count} videos)`
|
||
: '';
|
||
|
||
progressText.textContent = `Processing ${data.total_images} files${fileBreakdown}...`;
|
||
progressDetails.textContent = data.message || `Found ${data.total_images} media files to process`;
|
||
progressBar.style.width = '0%';
|
||
progressPercent.textContent = '0%';
|
||
progressETA.textContent = 'Calculating...';
|
||
|
||
addStatusMessage(`Found ${data.total_images} media files to process${fileBreakdown}`, 'info');
|
||
});
|
||
|
||
// Handle progress updates
|
||
eventSource.addEventListener('progress', (event) => {
|
||
if (isCancelled()) {
|
||
eventSource.close();
|
||
return;
|
||
}
|
||
const data = JSON.parse(event.data);
|
||
|
||
// Update progress bar
|
||
progressBar.style.width = `${data.percentage}%`;
|
||
progressPercent.textContent = `${data.percentage}%`;
|
||
|
||
// Update text with current file and action
|
||
const actionText = data.action === 'skipped' ? ' [Skipped]' : data.action === 'generating' ? ' [Generating]' : '';
|
||
// Show full path but truncate if too long
|
||
const fullPath = data.current_file || '';
|
||
const displayPath = fullPath.length > 60 ? '...' + fullPath.slice(-57) : fullPath;
|
||
progressText.textContent = `Processing ${data.file_type || 'file'} ${data.processed}/${data.total_images}: ${displayPath}${actionText}`;
|
||
|
||
// Update details
|
||
const elapsedText = data.elapsed ? ` | Time: ${Math.floor(data.elapsed)}s` : '';
|
||
const failedText = data.error_count ? `, Failed: ${data.error_count}` : '';
|
||
progressDetails.textContent = `Generated: ${data.generated}, Skipped: ${data.skipped}${failedText} | Rate: ${data.rate} img/s${elapsedText}`;
|
||
|
||
// Update ETA
|
||
if (data.eta > 0 && data.eta < 3600) {
|
||
const etaText = data.eta > 60 ? `${Math.floor(data.eta / 60)}m ${Math.floor(data.eta % 60)}s` : `${Math.floor(data.eta)}s`;
|
||
progressETA.textContent = `ETA: ${etaText}`;
|
||
} else {
|
||
progressETA.textContent = '';
|
||
}
|
||
|
||
// Add status message periodically
|
||
if (data.processed % 50 === 0 || data.processed === data.total_images) {
|
||
addStatusMessage(`Processed ${data.processed}/${data.total_images} files (${data.generated} generated, ${data.skipped} skipped)`, 'info');
|
||
}
|
||
});
|
||
|
||
// Handle completion
|
||
eventSource.addEventListener('complete', (event) => {
|
||
if (isCancelled()) {
|
||
eventSource.close();
|
||
return;
|
||
}
|
||
resultData = JSON.parse(event.data);
|
||
|
||
// Update final progress
|
||
progressBar.style.width = '100%';
|
||
progressPercent.textContent = '100%';
|
||
progressText.textContent = 'Completed!';
|
||
|
||
// Show detailed completion message
|
||
const errorCount = resultData.error_count || (resultData.errors ? resultData.errors.length : 0);
|
||
const errors = errorCount > 0 ? ` (${errorCount} errors)` : '';
|
||
const rateText = resultData.processing_rate ? ` at ${resultData.processing_rate} img/s` : '';
|
||
progressDetails.textContent = `Generated: ${resultData.count} new, Skipped: ${resultData.skipped} | Total: ${resultData.total_images} files | Time: ${resultData.elapsed_time}s${rateText}${errors}`;
|
||
progressETA.textContent = 'Done';
|
||
|
||
// Add completion message
|
||
if (errorCount > 0) {
|
||
addStatusMessage(`Completed with ${errorCount} errors: Generated ${resultData.count} new thumbnails, skipped ${resultData.skipped} existing`, 'warning');
|
||
if (resultData.errors && resultData.errors.length > 0) {
|
||
// Show first few errors
|
||
resultData.errors.slice(0, 3).forEach(err => {
|
||
addStatusMessage(err, 'error');
|
||
});
|
||
}
|
||
} else {
|
||
addStatusMessage(`Successfully generated ${resultData.count} new thumbnails, skipped ${resultData.skipped} existing`, 'success');
|
||
}
|
||
|
||
|
||
// Clean up
|
||
eventSource.close();
|
||
resolve(resultData);
|
||
});
|
||
|
||
// Per-file failures: report them, keep listening
|
||
eventSource.addEventListener('file_error', (event) => {
|
||
if (isCancelled()) return;
|
||
let detail = 'Thumbnail failed';
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
detail = `Thumbnail failed: ${data.current_file || data.file || ''} ${data.error || ''}`.trim();
|
||
} catch (e) {
|
||
// non-JSON frame; keep the generic message
|
||
}
|
||
addStatusMessage(detail, 'error');
|
||
});
|
||
|
||
// Handle errors
|
||
eventSource.addEventListener('error', (event) => {
|
||
if (isCancelled()) {
|
||
eventSource.close();
|
||
return;
|
||
}
|
||
|
||
let errorMsg = 'Thumbnail generation failed';
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
errorMsg = data.error || errorMsg;
|
||
} catch (e) {
|
||
// If not JSON, it's a connection error
|
||
console.error('EventSource error in modal:', e);
|
||
}
|
||
|
||
addStatusMessage(errorMsg, 'error');
|
||
progressBar.classList.add('bg-pm-error');
|
||
progressBar.classList.remove('bg-pm-accent');
|
||
|
||
// Clean up
|
||
eventSource.close();
|
||
reject(new Error(errorMsg));
|
||
});
|
||
|
||
// Handle EventSource connection errors
|
||
eventSource.onerror = (error) => {
|
||
if (isCancelled()) {
|
||
eventSource.close();
|
||
return;
|
||
}
|
||
|
||
console.error('EventSource connection error in modal:', error);
|
||
|
||
// Check if connection is closing normally (after complete event)
|
||
if (eventSource.readyState === eventSource.CLOSED && resultData) {
|
||
return;
|
||
}
|
||
|
||
addStatusMessage('Connection to server lost', 'error');
|
||
progressBar.classList.add('bg-pm-error');
|
||
progressBar.classList.remove('bg-pm-accent');
|
||
|
||
// Clean up
|
||
eventSource.close();
|
||
|
||
if (!resultData) {
|
||
reject(new Error('Connection to server lost during thumbnail generation'));
|
||
}
|
||
};
|
||
|
||
// Stream ended without a complete/error frame (close() does not trigger this)
|
||
eventSource.onclose = () => {
|
||
if (isCancelled() || resultData) return;
|
||
addStatusMessage('Connection to server closed before thumbnail generation finished', 'error');
|
||
reject(new Error('Connection to server closed before thumbnail generation finished'));
|
||
};
|
||
});
|
||
}
|
||
|
||
// ==========================================
|
||
// Add Prompt Feature Methods
|
||
// ==========================================
|
||
|
||
// State for Add Prompt feature
|
||
addPromptState = {
|
||
allTags: [],
|
||
allCategories: [],
|
||
selectedTags: [],
|
||
selectedRating: null
|
||
};
|
||
|
||
async showAddPromptModal() {
|
||
// Reset form
|
||
this.resetAddPromptForm();
|
||
|
||
// Load tags and categories for autocomplete
|
||
await this.loadTagsAndCategories();
|
||
|
||
this.showModal('addPromptModal');
|
||
document.getElementById('addPromptText').focus();
|
||
}
|
||
|
||
hideAddPromptModal() {
|
||
this.hideModal('addPromptModal');
|
||
this.hideSuggestions();
|
||
}
|
||
|
||
resetAddPromptForm() {
|
||
document.getElementById('addPromptText').value = '';
|
||
document.getElementById('addPromptCategory').value = '';
|
||
document.getElementById('addPromptNotes').value = '';
|
||
document.getElementById('addPromptTagInput').value = '';
|
||
document.getElementById('addPromptProtected').checked = true;
|
||
document.getElementById('addPromptRatingValue').value = '';
|
||
this.addPromptState.selectedTags = [];
|
||
this.addPromptState.selectedRating = null;
|
||
this.renderSelectedTags();
|
||
this.updateRatingStars(0);
|
||
}
|
||
|
||
async loadTagsAndCategories() {
|
||
try {
|
||
// Load tags
|
||
const tagsResponse = await ApiClient.fetchUrl('/prompt_manager/tags');
|
||
const tagsData = await tagsResponse.json();
|
||
if (tagsData.success) {
|
||
// Filter out the __protected__ tag from suggestions
|
||
this.addPromptState.allTags = tagsData.tags.filter(tag => tag !== '__protected__');
|
||
}
|
||
|
||
// Load categories
|
||
const categoriesResponse = await ApiClient.fetchUrl('/prompt_manager/categories');
|
||
const categoriesData = await categoriesResponse.json();
|
||
if (categoriesData.success) {
|
||
this.addPromptState.allCategories = categoriesData.categories;
|
||
this.populateCategoryDatalist();
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading tags/categories:', error);
|
||
}
|
||
}
|
||
|
||
populateCategoryDatalist() {
|
||
const datalist = document.getElementById('categoryList');
|
||
datalist.innerHTML = this.addPromptState.allCategories
|
||
.map(cat => `<option value="${escapeHtml(cat)}">`)
|
||
.join('');
|
||
}
|
||
|
||
handleTagInput(e) {
|
||
const query = e.target.value.toLowerCase().trim();
|
||
|
||
if (query.length < 1) {
|
||
this.hideSuggestions();
|
||
return;
|
||
}
|
||
|
||
// Filter tags that match query and aren't already selected
|
||
const suggestions = this.addPromptState.allTags
|
||
.filter(tag =>
|
||
tag.toLowerCase().includes(query) &&
|
||
!this.addPromptState.selectedTags.includes(tag)
|
||
)
|
||
.slice(0, 10);
|
||
|
||
if (suggestions.length > 0) {
|
||
this.showSuggestions(suggestions);
|
||
} else {
|
||
this.hideSuggestions();
|
||
}
|
||
}
|
||
|
||
handleTagKeydown(e) {
|
||
if (e.key === 'Enter' || e.key === ',') {
|
||
e.preventDefault();
|
||
const input = e.target;
|
||
const value = input.value.replace(',', '').trim();
|
||
|
||
if (value && !this.addPromptState.selectedTags.includes(value)) {
|
||
this.addTag(value);
|
||
input.value = '';
|
||
this.hideSuggestions();
|
||
}
|
||
} else if (e.key === 'Backspace' && e.target.value === '') {
|
||
// Remove last tag on backspace with empty input
|
||
if (this.addPromptState.selectedTags.length > 0) {
|
||
this.removeTag(this.addPromptState.selectedTags.length - 1);
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
this.hideSuggestions();
|
||
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||
this.navigateSuggestions(e.key === 'ArrowDown' ? 1 : -1);
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
|
||
showSuggestions(suggestions) {
|
||
const container = document.getElementById('tagSuggestions');
|
||
container.innerHTML = suggestions
|
||
.map((tag, index) => `
|
||
<div class="px-3 py-2 hover:bg-pm-hover cursor-pointer text-pm text-sm suggestion-item"
|
||
data-index="${index}"
|
||
data-action="select-suggestion" data-tag="${escapeHtml(tag)}">
|
||
${escapeHtml(tag)}
|
||
</div>
|
||
`)
|
||
.join('');
|
||
container.classList.remove('hidden');
|
||
}
|
||
|
||
hideSuggestions() {
|
||
document.getElementById('tagSuggestions').classList.add('hidden');
|
||
}
|
||
|
||
navigateSuggestions(direction) {
|
||
const container = document.getElementById('tagSuggestions');
|
||
const items = container.querySelectorAll('.suggestion-item');
|
||
if (items.length === 0) return;
|
||
|
||
const current = container.querySelector('.bg-pm-hover');
|
||
let nextIndex = 0;
|
||
|
||
if (current) {
|
||
current.classList.remove('bg-pm-hover');
|
||
nextIndex = parseInt(current.dataset.index) + direction;
|
||
if (nextIndex < 0) nextIndex = items.length - 1;
|
||
if (nextIndex >= items.length) nextIndex = 0;
|
||
} else if (direction === -1) {
|
||
nextIndex = items.length - 1;
|
||
}
|
||
|
||
items[nextIndex].classList.add('bg-pm-hover');
|
||
}
|
||
|
||
selectSuggestion(tag) {
|
||
this.addTag(tag);
|
||
document.getElementById('addPromptTagInput').value = '';
|
||
this.hideSuggestions();
|
||
document.getElementById('addPromptTagInput').focus();
|
||
}
|
||
|
||
addTag(tag) {
|
||
if (!this.addPromptState.selectedTags.includes(tag)) {
|
||
this.addPromptState.selectedTags.push(tag);
|
||
this.renderSelectedTags();
|
||
}
|
||
}
|
||
|
||
removeTag(index) {
|
||
this.addPromptState.selectedTags.splice(index, 1);
|
||
this.renderSelectedTags();
|
||
}
|
||
|
||
renderSelectedTags() {
|
||
const container = document.getElementById('addPromptTagsContainer');
|
||
const input = document.getElementById('addPromptTagInput');
|
||
|
||
// Remove existing tag chips (keep only the input)
|
||
container.querySelectorAll('.tag-chip').forEach(el => el.remove());
|
||
|
||
// Add tag chips before the input
|
||
this.addPromptState.selectedTags.forEach((tag, index) => {
|
||
const chip = document.createElement('span');
|
||
chip.className = 'tag-chip';
|
||
chip.innerHTML = `
|
||
${escapeHtml(tag)}
|
||
<span class="tag-remove" data-action="remove-tag" data-index="${index}">×</span>
|
||
`;
|
||
container.insertBefore(chip, input);
|
||
});
|
||
}
|
||
|
||
setRating(rating) {
|
||
this.addPromptState.selectedRating = rating;
|
||
document.getElementById('addPromptRatingValue').value = rating;
|
||
this.updateRatingStars(rating);
|
||
}
|
||
|
||
clearRating() {
|
||
this.addPromptState.selectedRating = null;
|
||
document.getElementById('addPromptRatingValue').value = '';
|
||
this.updateRatingStars(0);
|
||
}
|
||
|
||
updateRatingStars(rating) {
|
||
const stars = document.querySelectorAll('#addPromptRating .rating-star');
|
||
stars.forEach((star, index) => {
|
||
if (index < rating) {
|
||
star.textContent = '★';
|
||
star.classList.remove('text-pm-muted');
|
||
star.classList.add('text-pm-warning');
|
||
} else {
|
||
star.textContent = '☆';
|
||
star.classList.remove('text-pm-warning');
|
||
star.classList.add('text-pm-muted');
|
||
}
|
||
});
|
||
}
|
||
|
||
async saveNewPrompt() {
|
||
const text = document.getElementById('addPromptText').value.trim();
|
||
const category = document.getElementById('addPromptCategory').value.trim();
|
||
const notes = document.getElementById('addPromptNotes').value.trim();
|
||
const isProtected = document.getElementById('addPromptProtected').checked;
|
||
const rating = this.addPromptState.selectedRating;
|
||
|
||
// Validation
|
||
if (!text) {
|
||
this.showNotification('Prompt text is required', 'error');
|
||
document.getElementById('addPromptText').focus();
|
||
return;
|
||
}
|
||
|
||
// Build tags array - add __protected__ if checkbox is checked
|
||
let tags = [...this.addPromptState.selectedTags];
|
||
if (isProtected && !tags.includes('__protected__')) {
|
||
tags.push('__protected__');
|
||
}
|
||
|
||
// Disable button during save
|
||
const saveBtn = document.getElementById('saveNewPromptBtn');
|
||
saveBtn.disabled = true;
|
||
saveBtn.textContent = 'Saving...';
|
||
|
||
try {
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/save', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
text: text,
|
||
category: category || null,
|
||
tags: tags,
|
||
rating: rating,
|
||
notes: notes || null
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
const message = data.is_duplicate
|
||
? 'Prompt already exists (updated metadata)'
|
||
: 'Prompt added successfully!';
|
||
this.showNotification(message, 'success');
|
||
this.hideAddPromptModal();
|
||
} else {
|
||
this.showNotification(`Failed to save prompt: ${data.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error saving prompt:', error);
|
||
this.showNotification('Failed to save prompt', 'error');
|
||
} finally {
|
||
saveBtn.disabled = false;
|
||
saveBtn.textContent = '➕ Add Prompt';
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// Auto Tag Feature Methods
|
||
// ==========================================
|
||
|
||
// State for AutoTag feature
|
||
autoTagState = {
|
||
eventSource: null,
|
||
downloadEventSource: null,
|
||
reviewImages: [],
|
||
reviewIndex: 0,
|
||
currentTags: [],
|
||
cancelled: false
|
||
};
|
||
|
||
showModal(modalId) {
|
||
const modal = document.getElementById(modalId);
|
||
modal.classList.remove('hidden');
|
||
modal.classList.add('flex');
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
hideModal(modalId) {
|
||
const modal = document.getElementById(modalId);
|
||
modal.classList.add('hidden');
|
||
modal.classList.remove('flex');
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
async showAutoTagModal() {
|
||
this.showModal("autoTagModal");
|
||
await this.checkAutoTagModels();
|
||
}
|
||
|
||
// _setModelStatus uses innerHTML with hardcoded model keys (not user input)
|
||
// to render download buttons or status indicators for known model types
|
||
_setModelStatus(el, downloaded, modelKey) {
|
||
if (downloaded) {
|
||
el.textContent = '';
|
||
const span = document.createElement('span');
|
||
span.className = 'text-pm-success text-sm';
|
||
span.textContent = '\u2713 Downloaded';
|
||
el.appendChild(span);
|
||
} else {
|
||
el.textContent = '';
|
||
const btn = document.createElement('button');
|
||
btn.className = 'px-3 py-1 bg-pm-accent hover:bg-pm-accent-hover text-pm text-xs rounded transition-colors';
|
||
btn.textContent = 'Download';
|
||
btn.addEventListener('click', () => window.gallery.downloadModel(modelKey));
|
||
el.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
async checkAutoTagModels() {
|
||
const ggufStatus = document.getElementById("ggufModelStatus");
|
||
const hfStatus = document.getElementById("hfModelStatus");
|
||
const wd14SwinV2Status = document.getElementById("wd14SwinV2ModelStatus");
|
||
const wd14VitStatus = document.getElementById("wd14VitModelStatus");
|
||
const modelLoadedStatus = document.getElementById("modelLoadedStatus");
|
||
const loadedModelType = document.getElementById("loadedModelType");
|
||
const unloadModelBtn = document.getElementById("unloadModelBtn");
|
||
|
||
const statusEls = [ggufStatus, hfStatus, wd14SwinV2Status, wd14VitStatus];
|
||
statusEls.forEach(el => { el.textContent = 'Checking...'; });
|
||
|
||
try {
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
this._setModelStatus(ggufStatus, data.models.gguf.downloaded, 'gguf');
|
||
this._setModelStatus(hfStatus, data.models.hf.downloaded, 'hf');
|
||
this._setModelStatus(wd14SwinV2Status, data.models['wd14-swinv2']?.downloaded, 'wd14-swinv2');
|
||
this._setModelStatus(wd14VitStatus, data.models['wd14-vit']?.downloaded, 'wd14-vit');
|
||
|
||
// Update model loaded status
|
||
if (data.model_loaded && data.loaded_model_type) {
|
||
modelLoadedStatus.classList.remove('hidden');
|
||
loadedModelType.textContent = data.loaded_model_type.toUpperCase();
|
||
unloadModelBtn.classList.remove('hidden');
|
||
} else {
|
||
modelLoadedStatus.classList.add('hidden');
|
||
unloadModelBtn.classList.add('hidden');
|
||
}
|
||
|
||
// Update WD14 threshold defaults from server
|
||
if (data.wd14_general_threshold !== undefined) {
|
||
const genSlider = document.getElementById('wd14GeneralThreshold');
|
||
genSlider.value = data.wd14_general_threshold;
|
||
document.getElementById('wd14GeneralThresholdValue').textContent = parseFloat(data.wd14_general_threshold).toFixed(2);
|
||
}
|
||
if (data.wd14_character_threshold !== undefined) {
|
||
const charSlider = document.getElementById('wd14CharacterThreshold');
|
||
charSlider.value = data.wd14_character_threshold;
|
||
document.getElementById('wd14CharacterThresholdValue').textContent = parseFloat(data.wd14_character_threshold).toFixed(2);
|
||
}
|
||
} else {
|
||
statusEls.forEach(el => { el.textContent = 'Error'; });
|
||
}
|
||
} catch (error) {
|
||
console.error('Error checking models:', error);
|
||
statusEls.forEach(el => { el.textContent = 'Error'; });
|
||
}
|
||
}
|
||
|
||
async downloadModel(modelType) {
|
||
const modelNames = {
|
||
'gguf': 'GGUF Model',
|
||
'hf': 'HuggingFace Model',
|
||
'wd14-swinv2': 'WD14 SwinV2',
|
||
'wd14-vit': 'WD14 ViT'
|
||
};
|
||
const modelName = modelNames[modelType] || modelType;
|
||
document.getElementById('downloadModelName').textContent = modelName;
|
||
document.getElementById('downloadStatus').textContent = 'Preparing...';
|
||
document.getElementById('downloadProgressPercent').textContent = '0%';
|
||
document.getElementById('downloadProgressBar').style.width = '0%';
|
||
|
||
this.hideModal("autoTagModal");
|
||
this.showModal("autoTagDownloadModal");
|
||
|
||
try {
|
||
this.autoTagState.downloadEventSource = SseStream.connect(`/prompt_manager/autotag/download/${modelType}`, { method: 'POST' });
|
||
|
||
this.autoTagState.downloadEventSource.onmessage = (event) => {
|
||
const data = JSON.parse(event.data);
|
||
|
||
if (data.status === 'downloading') {
|
||
const percent = Math.round(data.progress * 100);
|
||
document.getElementById('downloadStatus').textContent = data.message || 'Downloading...';
|
||
document.getElementById('downloadProgressPercent').textContent = `${percent}%`;
|
||
document.getElementById('downloadProgressBar').style.width = `${percent}%`;
|
||
} else if (data.status === 'complete') {
|
||
this.autoTagState.downloadEventSource.close();
|
||
this.hideModal("autoTagDownloadModal");
|
||
this.showModal("autoTagModal");
|
||
this.checkAutoTagModels();
|
||
this.showNotification('Model downloaded successfully!', 'success');
|
||
} else if (data.status === 'error') {
|
||
this.autoTagState.downloadEventSource.close();
|
||
this.hideModal("autoTagDownloadModal");
|
||
this.showModal("autoTagModal");
|
||
this.showNotification(`Download failed: ${data.message}`, 'error');
|
||
}
|
||
};
|
||
|
||
this.autoTagState.downloadEventSource.onerror = () => {
|
||
this.autoTagState.downloadEventSource.close();
|
||
this.hideModal("autoTagDownloadModal");
|
||
this.showModal("autoTagModal");
|
||
this.showNotification('Download connection lost', 'error');
|
||
};
|
||
} catch (error) {
|
||
console.error('Download error:', error);
|
||
this.hideModal("autoTagDownloadModal");
|
||
this.showModal("autoTagModal");
|
||
this.showNotification('Failed to start download', 'error');
|
||
}
|
||
}
|
||
|
||
cancelDownload() {
|
||
if (this.autoTagState.downloadEventSource) {
|
||
this.autoTagState.downloadEventSource.close();
|
||
this.autoTagState.downloadEventSource = null;
|
||
}
|
||
this.hideModal("autoTagDownloadModal");
|
||
this.showModal("autoTagModal");
|
||
}
|
||
|
||
async unloadModel() {
|
||
const unloadBtn = document.getElementById("unloadModelBtn");
|
||
unloadBtn.disabled = true;
|
||
unloadBtn.textContent = 'Unloading...';
|
||
|
||
try {
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/unload', {
|
||
method: 'POST'
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
this.showNotification(data.message, 'success');
|
||
await this.checkAutoTagModels(); // Refresh status
|
||
} else {
|
||
this.showNotification(`Failed to unload model: ${data.error}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error unloading model:', error);
|
||
this.showNotification('Failed to unload model', 'error');
|
||
} finally {
|
||
unloadBtn.disabled = false;
|
||
unloadBtn.textContent = 'Unload Model';
|
||
}
|
||
}
|
||
|
||
async startAutoTag() {
|
||
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
|
||
const prompt = document.getElementById('autoTagPrompt').value;
|
||
const keepInMemory = document.getElementById('keepModelInMemory').checked;
|
||
|
||
// Check if model is downloaded
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
|
||
const data = await response.json();
|
||
|
||
if (data.success && !data.models[modelType].downloaded) {
|
||
this.showNotification('Please download the selected model first', 'warning');
|
||
return;
|
||
}
|
||
|
||
this.hideModal("autoTagModal");
|
||
this.showModal("autoTagProgressModal");
|
||
|
||
// Reset progress display
|
||
document.getElementById('autoTagCurrentFile').textContent = 'Loading model...';
|
||
document.getElementById('autoTagProgressPercent').textContent = '0%';
|
||
document.getElementById('autoTagProgressBar').style.width = '0%';
|
||
document.getElementById('autoTagProcessed').textContent = '0';
|
||
document.getElementById('autoTagApplied').textContent = '0';
|
||
document.getElementById('autoTagSkipped').textContent = '0';
|
||
|
||
this.autoTagState.cancelled = false;
|
||
|
||
try {
|
||
const formData = new URLSearchParams();
|
||
formData.append('model_type', modelType);
|
||
formData.append('keep_in_memory', keepInMemory);
|
||
|
||
if (modelType.startsWith('wd14')) {
|
||
formData.append('general_threshold', document.getElementById('wd14GeneralThreshold').value);
|
||
formData.append('character_threshold', document.getElementById('wd14CharacterThreshold').value);
|
||
} else {
|
||
formData.append('prompt', prompt);
|
||
}
|
||
|
||
this.autoTagState.eventSource = SseStream.connect(`/prompt_manager/autotag/start?${formData.toString()}`, { method: 'POST' });
|
||
|
||
this.autoTagState.eventSource.onmessage = (event) => {
|
||
const data = JSON.parse(event.data);
|
||
|
||
if (data.status === 'loading_model') {
|
||
document.getElementById('autoTagCurrentFile').textContent = 'Loading model...';
|
||
} else if (data.status === 'processing') {
|
||
const percent = Math.round((data.processed / data.total) * 100);
|
||
document.getElementById('autoTagCurrentFile').textContent = data.current_file || 'Processing...';
|
||
document.getElementById('autoTagProgressPercent').textContent = `${percent}%`;
|
||
document.getElementById('autoTagProgressBar').style.width = `${percent}%`;
|
||
document.getElementById('autoTagProcessed').textContent = data.processed;
|
||
document.getElementById('autoTagApplied').textContent = data.tags_applied;
|
||
document.getElementById('autoTagSkipped').textContent = data.skipped;
|
||
} else if (data.status === 'complete') {
|
||
this.autoTagState.eventSource.close();
|
||
this.hideModal("autoTagProgressModal");
|
||
this.showNotification(`Auto tagging complete! Applied tags to ${data.tags_applied} prompts.`, 'success');
|
||
this.loadImages(); // Refresh the gallery
|
||
} else if (data.status === 'error') {
|
||
this.autoTagState.eventSource.close();
|
||
this.hideModal("autoTagProgressModal");
|
||
this.showNotification(`Auto tag error: ${data.message}`, 'error');
|
||
} else if (data.status === 'cancelled') {
|
||
this.autoTagState.eventSource.close();
|
||
this.hideModal("autoTagProgressModal");
|
||
this.showNotification('Auto tagging cancelled', 'info');
|
||
}
|
||
};
|
||
|
||
this.autoTagState.eventSource.onerror = () => {
|
||
this.autoTagState.eventSource.close();
|
||
this.hideModal("autoTagProgressModal");
|
||
this.showNotification('Connection lost during auto tagging', 'error');
|
||
};
|
||
} catch (error) {
|
||
console.error('Auto tag error:', error);
|
||
this.hideModal("autoTagProgressModal");
|
||
this.showNotification('Failed to start auto tagging', 'error');
|
||
}
|
||
}
|
||
|
||
cancelAutoTag() {
|
||
this.autoTagState.cancelled = true;
|
||
if (this.autoTagState.eventSource) {
|
||
this.autoTagState.eventSource.close();
|
||
this.autoTagState.eventSource = null;
|
||
}
|
||
this.hideModal("autoTagProgressModal");
|
||
this.showNotification('Auto tagging cancelled', 'info');
|
||
}
|
||
|
||
async startReview() {
|
||
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
|
||
|
||
// Check if model is downloaded
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/models');
|
||
const data = await response.json();
|
||
|
||
if (data.success && !data.models[modelType].downloaded) {
|
||
this.showNotification('Please download the selected model first', 'warning');
|
||
return;
|
||
}
|
||
|
||
this.hideModal("autoTagModal");
|
||
|
||
// Use current gallery images for review
|
||
if (!this.images || this.images.length === 0) {
|
||
this.showNotification('No images found in gallery', 'warning');
|
||
return;
|
||
}
|
||
|
||
this.autoTagState.reviewImages = this.images;
|
||
this.autoTagState.reviewIndex = 0;
|
||
this.autoTagState.modelType = modelType;
|
||
this.autoTagState.prompt = document.getElementById('autoTagPrompt').value;
|
||
this.autoTagState.generalThreshold = parseFloat(document.getElementById('wd14GeneralThreshold').value);
|
||
this.autoTagState.characterThreshold = parseFloat(document.getElementById('wd14CharacterThreshold').value);
|
||
|
||
document.getElementById('reviewTotalCount').textContent = this.images.length;
|
||
|
||
this.showModal("autoTagReviewModal");
|
||
await this.loadNextReviewImage();
|
||
}
|
||
|
||
async loadNextReviewImage() {
|
||
if (this.autoTagState.reviewIndex >= this.autoTagState.reviewImages.length) {
|
||
this.hideModal("autoTagReviewModal");
|
||
this.showNotification('Review complete!', 'success');
|
||
this.loadImages();
|
||
return;
|
||
}
|
||
|
||
const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex];
|
||
document.getElementById('reviewCurrentIndex').textContent = this.autoTagState.reviewIndex + 1;
|
||
document.getElementById('reviewImage').src = ApiClient.resolveUrl(image.thumbnail_url || image.url);
|
||
document.getElementById('reviewImageName').textContent = image.filename || 'Unknown';
|
||
document.getElementById('reviewTagsContainer').innerHTML = '<div class="text-pm-secondary">Generating tags...</div>';
|
||
|
||
try {
|
||
// Gallery entries come from /images/output: id is a path digest, so address by path
|
||
const requestBody = ImageHelpers.autotagSingleBody(image, this.autoTagState);
|
||
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/single', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(requestBody)
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
this.autoTagState.currentTags = data.tags;
|
||
this.renderReviewTags();
|
||
} else {
|
||
document.getElementById('reviewTagsContainer').innerHTML =
|
||
`<div class="text-pm-error">Error: ${escapeHtml(data.error)}</div>`;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error generating tags:', error);
|
||
document.getElementById('reviewTagsContainer').innerHTML =
|
||
'<div class="text-pm-error">Failed to generate tags</div>';
|
||
}
|
||
}
|
||
|
||
renderReviewTags() {
|
||
const container = document.getElementById('reviewTagsContainer');
|
||
if (this.autoTagState.currentTags.length === 0) {
|
||
container.innerHTML = '<div class="text-pm-secondary">No tags generated</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = this.autoTagState.currentTags.map((tag, index) => `
|
||
<span class="tag-chip">
|
||
${escapeHtml(tag)}
|
||
<span class="tag-remove" data-action="remove-review-tag" data-index="${index}">×</span>
|
||
</span>
|
||
`).join('');
|
||
}
|
||
|
||
removeReviewTag(index) {
|
||
this.autoTagState.currentTags.splice(index, 1);
|
||
this.renderReviewTags();
|
||
}
|
||
|
||
skipReviewImage() {
|
||
this.autoTagState.reviewIndex++;
|
||
this.loadNextReviewImage();
|
||
}
|
||
|
||
async applyReviewTags() {
|
||
if (this.autoTagState.currentTags.length === 0) {
|
||
this.skipReviewImage();
|
||
return;
|
||
}
|
||
|
||
const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex];
|
||
|
||
try {
|
||
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/apply', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
image_path: image.path,
|
||
tags: this.autoTagState.currentTags
|
||
})
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.success) {
|
||
this.showNotification(`Applied ${this.autoTagState.currentTags.length} tags`, 'success');
|
||
} else {
|
||
this.showNotification(data.error || 'Failed to apply tags', 'warning');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error applying tags:', error);
|
||
this.showNotification('Failed to apply tags', 'error');
|
||
}
|
||
|
||
this.autoTagState.reviewIndex++;
|
||
this.loadNextReviewImage();
|
||
}
|
||
|
||
cancelReview() {
|
||
this.autoTagState.reviewImages = [];
|
||
this.autoTagState.reviewIndex = 0;
|
||
this.autoTagState.currentTags = [];
|
||
this.hideModal("autoTagReviewModal");
|
||
}
|
||
|
||
notifyThumbnailsDone(result) {
|
||
// A run stops at the server's per-run cap; say so instead of claiming it is finished.
|
||
const remaining = Number(result && result.remaining) || 0;
|
||
if (remaining > 0) {
|
||
this.showNotification(
|
||
`${remaining} thumbnails still pending. Run "Generate Thumbnails" again to continue.`,
|
||
'warning'
|
||
);
|
||
return;
|
||
}
|
||
this.showNotification('Thumbnails generated successfully!', 'success');
|
||
}
|
||
|
||
showNotification(message, type = 'info') {
|
||
// Simple notification - could be enhanced with a toast library
|
||
const colors = {
|
||
success: 'bg-pm-success',
|
||
error: 'bg-pm-error',
|
||
warning: 'bg-pm-warning',
|
||
info: 'bg-pm-accent'
|
||
};
|
||
|
||
const notification = document.createElement('div');
|
||
notification.className = `fixed bottom-4 right-4 ${colors[type]} text-pm px-4 py-2 rounded-pm-md shadow-pm text-sm z-50 animate-fade-in`;
|
||
notification.textContent = message;
|
||
document.body.appendChild(notification);
|
||
|
||
setTimeout(() => {
|
||
notification.remove();
|
||
}, 3000);
|
||
}
|
||
}
|
||
|
||
// Initialize gallery when page loads
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
window.gallery = new GalleryManager();
|
||
});
|
||
|
||
// Global helper for modal close buttons
|
||
function hideModal(modalId) {
|
||
const modal = document.getElementById(modalId);
|
||
modal.classList.add('hidden');
|
||
modal.classList.remove('flex');
|
||
document.body.style.overflow = '';
|
||
}
|