Files
ComfyAssets-ComfyUI_PromptM…/web/js/gallery.js
T
Vito 9ab25f9aaa feat(system): Maintenance UI (System S7) (#322)
* fix(gallery): clear only Prompt Manager cache entries (#222)

The legacy gallery's Clear cache deleted every localStorage key starting
pm_ or promptManager_ (the workspace theme and the dismissed update notice
among them) and every CacheStorage cache whose name held gallery or
thumbnail, which on ComfyUI's shared origin can belong to another tool.

web/js/cache-ownership.js names what a clear may remove from an inventory
of the real writers: every key Prompt Manager writes is a preference and no
page opens CacheStorage or IndexedDB, so nothing is removed and the toast
says so. A test fails when a new storage writer is not classified. The
server thumbnail clear stays its own request; a refused or unreachable
server changes nothing and is reported, and refused browser storage is
reported as such.

* feat(system): run reviewed maintenance natively

System › Maintenance is native: the concept's notice and its Database and
Images & cache surfaces replace the hand-off.

- Integrity & relationship checks runs the health check; the report's
  Review missing links reviews a fresh Missing-file links plan.
- Review plan shows the server's read-only plan (D100): scope, protected,
  backup policy and media lines, the listed records, how long the review
  holds and the backup box; Apply reviewed operation sends only its token
  and opens the job in Activity. busy names the running job, plan-used links
  the job that applied it, an expired or replaced review is reviewed again,
  and a lost answer says Outcome not confirmed and points to Activity.
- Find duplicates starts the search; its view offers Review groups (D101):
  ten groups at a time, Keep radios per copy, each keeper sent as
  {group_id, root_index, path}; the dialog says History keeps the removed
  paths in its run records.
- Thumbnail cache opens the thumbnail dialog; Metadata cache is disabled
  with its reason while nothing is owned (D102, Q5).
- Activity descriptors for maintenance, duplicates and duplicate_cleanup.
- The legacy duplicates dialog's View Full names its source (?root=).

* test(system): compare Maintenance with the concept

system-maintenance-concept.spec compares System › Maintenance, the plan
dialog of each operation and the duplicates dialog with the concept in both
themes and both viewports, mapped only by D100–D102, and proves a mutated
row, label, danger mark or token is noticed. The duplicates dialog's Review
duplicate cleanup is the concept's plain button.

* test(system): Maintenance no longer hands off; raise coverage floors

The System shell test expected Maintenance's D94 hand-off; the section is
native now. coverage:update sets the new modules' floors from the
measured run (cache-ownership 94, maintenance-review 94,
system-maintenance 92 lines) and raises the floors other files passed.

* test(admin): close the preview worker after the last job in teardown

A scan publishes its last event, which ends the stream the test reads,
before it hands its missing previews to the automatic worker. That
hand-off can run after tearDownAsync closed the worker, so it starts a new
one whose .part files were still open when the final drain checked for
open handles (py3.10 on ubuntu, test_scan_reads_metadata_on_several_threads).
The drain now closes the worker for good once every job has ended.

* fix(system): address the S7 final review

- I1: an apply answer is a refusal only when our server sent it (a known
  code, or a 4xx whose body says success:false); a proxy's 5xx, an
  unreadable 2xx or a dropped connection says Outcome not confirmed and
  reads Activity again. ApiError gains 'refused'; the server's own
  pre-start failure is coded 'apply-failed'.
- M1: the lost-response test lets the apply run, then drops its answer.
- M2: a failed apply records its code in the job's scope; Activity offers
  Review again for an overtaken plan (Maintenance for a duplicate cleanup
  whose sources changed).
- M3: a failed duplicate cleanup toasts the server's reason.
- M4: a review button holds while its preview is read (one preview per
  double press).
- M5: the storage guard also catches bracket and property assignment and
  CacheStorage/IndexedDB opens; why the Toolkit build is skipped is
  written down.
- M6: a refused storage listing says so on the Metadata cache row.
- M7, M8: D100 text corrected; workflow_storage has its title.
- M9: a busy start names the job the server named.

* test: follow #319 in the Maintenance tests

- The thumbnail dialog has only Scope since #319.
- The admin teardown refuses new preview workers on its own API instead
  of closing with final=True, which since #319 also shuts the process-wide
  encoders that later test modules use.

* fix(system): keep the server's search-again line on a duplicate review

A duplicate cleanup review the server refuses with 409 sources-changed
showed the generic "could not be reviewed" message; it now shows the
server's "The sources changed since the search. Search for duplicates
again."

* fix(system): a plan with nothing to do offers only Close

A reviewed maintenance plan whose scope is 0 records ("No duplicate
prompt text") offered Apply reviewed operation, which started an empty
job. It now shows its rows, "There is nothing to apply; nothing was
changed." and a Close button.

* fix(system): wrap long copy paths in the duplicates review

A Keep label is the copy's source and path, and dataset folders often have
long names with no space to break at, so the review scrolled sideways.
The label now wraps anywhere inside its compare item (D101).
2026-10-25 05:51:57 -07:00

3800 lines
200 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class GalleryManager {
// A Cancel on a thumbnail job another tab or System started (#249 System S5, review I1).
static THUMBNAILS_STOPPED_FOLLOWING = 'Stopped following thumbnail generation started elsewhere; it keeps running in Activity';
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();
// The page's lifetime ends when it starts to leave: WebKit fails a fetch a leaving page starts
// ("Fetch API cannot load … due to access control checks.") and logs it as a page error.
this.pageLifetime = new AbortController();
window.addEventListener("beforeunload", () => this.pageLifetime.abort());
window.addEventListener("pagehide", () => this.pageLifetime.abort());
window.addEventListener("pageshow", (e) => {
if (e.persisted && this.pageLifetime.signal.aborted) this.pageLifetime = new AbortController();
});
// 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;
// The listing names a preview URL for every file (made on view, #249); the dot means the file exists.
const hasThumb = typeof image.thumbnail_ready === 'boolean' ? image.thumbnail_ready : !!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;
// The listing names a preview URL for every file (made on view, #249); the dot means the file exists.
const hasThumb = typeof image.thumbnail_ready === 'boolean' ? image.thumbnail_ready : !!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 } = {}) {
this.metadataRequest = null;
this.currentMetadata = null;
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.metadataRequest = null;
this.currentMetadata = null;
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) {
const request = {};
this.metadataRequest = request;
this.currentMetadata = null;
container.textContent = 'Loading metadata...';
const isCurrent = () => this.metadataRequest === request && container.isConnected !== false;
try {
// First try to get linked prompt from database
let databasePrompt = null;
try {
const metadataUrl = ImageHelpers.metadataLookupUrl(imageUrl);
if (metadataUrl) {
const response = await ApiClient.fetchUrl(metadataUrl);
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 = {
textSource: databasePrompt.pair_context?.text_source,
positivePrompt: databasePrompt.pair_context?.positive_text ?? databasePrompt.text,
negativePrompt: databasePrompt.pair_context?.negative_text ?? databasePrompt.pair?.negative_text ?? 'No negative prompt found',
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 (!databasePrompt.pair && 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
}
}
if (isCurrent()) this.displayMetadata(metadata, container);
} catch (error) {
if (!isCurrent()) return;
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 textSourceLabel = new Map([
['execution','Recorded during execution'],
['graph-inferred','Recovered from workflow metadata'],
['embedded-parameters','Read from embedded parameters'],
['mixed','Execution and workflow metadata'],
]).get(metadata.textSource);
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>
${textSourceLabel ? `<p class="text-xs text-pm-secondary">${escapeHtml(textSourceLabel)}</p>` : ''}
<!-- 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() {
// The workspace shares this object (its layout and density, #249 System S3), so a Save starts from what is
// stored and keeps every key this dialog does not edit.
const settings = {
...this.getSettings(),
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),
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';
}
}
/** Ask the server to stop thumbnail job `jobId` at its next file boundary (#249 System S5). */
async cancelThumbnailJob(jobId) {
try {
await this.api.post(`/prompt_manager/jobs/${encodeURIComponent(jobId)}/cancel`, {});
return true;
} catch (error) {
console.warn('The thumbnail job could not be cancelled:', error);
return false;
}
}
/**
* Cancel for one thumbnail stream (#249 System S5, review I1). The stream's first frame, `job {id, started}`,
* names the job it follows and whether it started it. `request()` stops that job by its id, and only when this
* stream started it: a job another tab or System started keeps running, and the stream just stops following.
* A request before the `job` frame keeps the stream open until the frame arrives; `ended()` (the stream closed
* or refused without naming a job) leaves nothing to cancel.
*/
thumbnailCanceller(eventSource) {
let job = null;
let wanted = false;
let done = false;
const apply = () => {
if (done || !wanted || !job) return;
done = true;
eventSource.close();
if (job.started) this.cancelThumbnailJob(job.id);
};
return {
identify(raw) {
if (job) return;
let data = null;
try { data = JSON.parse(raw); } catch (error) { return; }
if (typeof data?.id !== 'string' || !data.id) return;
job = { id: data.id, started: data.started === true };
apply();
},
request() { wanted = true; apply(); },
/** True once the stream named a job this page did not start. */
get following() { return job !== null && !job.started; },
ended() {
if (done || !wanted || job) return;
done = true;
eventSource.close();
},
};
}
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;
const canceller = this.thumbnailCanceller(eventSource);
eventSource.addEventListener('job', event => canceller.identify(event.data));
// Handle cancellation. The work is an Activity job that outlives this stream, so closing it no
// longer stops anything: ask the server to stop this stream's job by its id (review I1).
const cancelHandler = () => {
if (cancelled) return;
cancelled = true;
cancelBtn.removeEventListener('click', cancelHandler);
cancelBtn.classList.add('hidden');
const following = canceller.following;
canceller.request();
reject(new Error(following ? GalleryManager.THUMBNAILS_STOPPED_FOLLOWING : '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) { canceller.ended(); 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) { canceller.ended(); 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) { canceller.ended(); 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) canceller.ended();
if (cancelled || resultData) return;
cancelBtn.removeEventListener('click', cancelHandler);
cancelBtn.classList.add('hidden');
reject(new Error('Connection to server closed before thumbnail generation finished'));
};
});
}
/**
* Clear cache (#222): the server's thumbnails by their own request, then only the browser entries Prompt
* Manager owns as a cache (cache-ownership.js; preferences and other tools' storage stay). Nothing is
* claimed that did not happen: a refused or lost server clear changes nothing here, and the toast says what
* the browser part removed, or that nothing of Prompt Manager's is cached, or that storage was refused.
*/
async clearCache() {
if (!confirm('Clear PromptManager cache and thumbnails? This will NOT affect your original images.')) return;
let response;
try {
response = await ApiClient.fetchUrl('/prompt_manager/images/clear-thumbnails', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
console.warn('Could not clear server thumbnails:', error);
this.showNotification('Thumbnails were not cleared: the server could not be reached.', 'error');
return;
}
if (!response.ok) {
// Refused (a running job holds the thumbnails): say why and claim nothing.
const data = await response.json().catch(() => ({}));
this.showNotification(`Thumbnails were not cleared: ${data.error || `HTTP ${response.status}`}`, 'error');
return;
}
// Read through a getter: a browser that refuses storage throws on `localStorage` itself.
const browser = await window.CacheOwnership.clearOwned({ storage: () => localStorage,
cacheStorage: 'caches' in window ? window.caches : null });
try {
const settings = this.getSettings();
settings.thumbnailsGenerated = false;
localStorage.setItem('gallerySettings', JSON.stringify(settings));
} catch (error) {
console.warn('Could not record the cleared thumbnails:', error);
}
document.getElementById('thumbnailStatus').textContent = 'Not generated';
this.showNotification(`Thumbnails cleared; original images untouched. ${window.CacheOwnership.clearMessage(browser)}`,
browser.partial ? 'warning' : 'success');
}
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;
}
// A leaving page starts no read, and one it aborts is not reported.
const signal = this.pageLifetime?.signal;
if (signal?.aborted) return;
try {
const data = await this.api.get('/prompt_manager/images/thumbnails/status', { signal });
const total = data.total_images;
const pending = data.pending;
if (!data.success || !Number.isSafeInteger(total) ||
!Number.isSafeInteger(pending) || total <= 0 ||
pending <= 0 || pending > total) return;
const signature = JSON.stringify([data.collection_id, total, pending]);
if (settings.thumbnailPromptDismissed === signature) return;
if (pending / total > 0.1) {
this.showThumbnailGenerationPrompt(total, pending, signature);
}
} 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, signature = null) {
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">
${missingCount} images need new or updated thumbnails (${imageCount} total images). Generate them for faster loading?
</p>
<p class="text-sm text-pm-muted mb-3">
Only missing or outdated thumbnails will be generated. Existing current thumbnails will be kept. You can change startup checks 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);
const dismiss = () => {
if (signature) {
try {
const settings = this.getSettings();
settings.thumbnailPromptDismissed = signature;
localStorage.setItem('gallerySettings', JSON.stringify(settings));
} catch (error) {
console.warn('Could not save thumbnail prompt dismissal:', error);
}
}
document.body.removeChild(modal);
};
document.getElementById('thumbnailPromptNo').addEventListener('click', dismiss);
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) {
dismiss();
}
});
}
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: the stream's own job stops at its next file boundary (#249 System S5, review I1)
let cancelStream = null;
cancelBtn.addEventListener('click', () => {
if (isCancelled) return;
isCancelled = true;
addStatusMessage('Cancellation requested...', 'warning');
cancelBtn.disabled = true;
cancelBtn.textContent = 'Cancelling...';
cancelStream?.();
});
// 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,
onCancel: handler => { cancelStream = handler; }
});
// 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) {
if (isCancelled) {
addStatusMessage(error.message, 'warning');
} else {
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, onCancel } = 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;
const canceller = this.thumbnailCanceller(eventSource);
eventSource.addEventListener('job', event => canceller.identify(event.data));
// Cancel stops this stream's job by id, keeps what it wrote, and settles the dialog.
onCancel?.(() => {
const following = canceller.following;
canceller.request();
reject(new Error(following ? GalleryManager.THUMBNAILS_STOPPED_FOLLOWING : 'Thumbnail generation cancelled; previews already written are kept'));
});
// 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() {
const request = {};
this.reviewRequest = request;
this.autoTagState.currentTags = [];
this.autoTagState.reviewPromptId = null;
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 (this.reviewRequest !== request) return;
if (data.success) {
this.autoTagState.reviewPromptId = data.prompt_id ?? null;
this.autoTagState.currentTags = data.tags;
this.renderReviewTags();
} else {
document.getElementById('reviewTagsContainer').innerHTML =
`<div class="text-pm-error">Error: ${escapeHtml(data.error)}</div>`;
}
} catch (error) {
if (this.reviewRequest !== request) return;
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 promptId = this.autoTagState.reviewPromptId;
if (!promptId) {
this.showNotification('This image has no linked prompt. Scan the output folder first.', 'warning');
return;
}
const request = this.reviewRequest;
if (this.reviewApplying === request) return;
this.reviewApplying = request;
try {
const response = await ApiClient.fetchUrl('/prompt_manager/autotag/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt_id: promptId,
tags: this.autoTagState.currentTags
})
});
const data = await response.json();
if (this.reviewRequest !== request) return;
if (data.success) {
this.showNotification(`Applied ${this.autoTagState.currentTags.length} tags`, 'success');
} else {
this.showNotification(data.error || 'Failed to apply tags', 'warning');
return;
}
} catch (error) {
if (this.reviewRequest !== request) return;
console.error('Error applying tags:', error);
this.showNotification('Failed to apply tags', 'error');
return;
} finally {
if (this.reviewApplying === request) this.reviewApplying = null;
}
this.autoTagState.reviewIndex++;
this.loadNextReviewImage();
}
cancelReview() {
this.reviewRequest = null;
this.autoTagState.reviewPromptId = null;
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 = '';
}