* test(legacy): snapshot the routes and the settings response Pins every registered route and the key paths of GET /prompt_manager/settings while System S13 (#330) moves the legacy pages' tools to native System. Recorded on the unchanged main; cutover (#334) removes it. * test(legacy): prove each removed legacy control has native coverage TESTING.md gains the "Legacy System UI removed by S13" table (no rows yet). legacy-coverage.test.mjs fails when a removal cites a capability that is not covered, copies another native test, lists an id or method twice, or leaves the id or method on its page. legacy-system-links.spec.mjs holds the System link table and guards the kept admin flows. * refactor(admin): open System for Diagnostics and logs The admin page's Diagnostics and View Logs buttons become links that open System › Diagnostics & logs in a new tab. The legacy Diagnostics and System Logs dialogs and their methods go; TESTING.md lists them against their covered capabilities (rows B1–B7). * refactor(admin): open System for settings and keep page options Settings opens System › Overview in a new tab. The settings dialog becomes Page options (opened from the list header) with only the Infinite Scrolling default, and its Save posts {"infinite_scroll": …} alone, so a stale legacy form can no longer overwrite scan folders or other values edited in System (#330 S13-9). Scan folders, monitoring status, auto-hide, display mode, worker threads, LoraManager and the LoRA import leave the page (rows B8–B14). * refactor(admin): open System for maintenance Maintenance opens System › Maintenance in a new tab, where each operation runs as a reviewed Activity job. The one-click legacy Database Maintenance dialog and its methods go (rows B15–B18); formatFileSize stays for the image modal. * refactor(admin): open System for backup and restore Backup DB and Restore DB open System › Backup & export in a new tab, where a restore is staged, reviewed and run as an Activity job. The one-click Restore dialog and its methods go (rows B19–B20); the scan dialog's quick backup stays. The removed browser tests' data checks live on at the route: a new round trip test restores every prompt with its tags and image links. * test(legacy): find a removed modal id passed to showModal and settle the links spec The removal check also finds an id passed as a string to a ...Modal() call and no longer reads data-id= as id=. The no-request test waits for the page's own start-up reads to finish, the sort check compares as the server sorts, and the kept-flow test checks the added prompt shows in the list. * test(admin): open Page options after the saved default is read Drops the leftover LoraManager section comment above the Page options save. * refactor(gallery): open System for settings and maintenance The gallery's Settings opens System › Appearance in a new tab. Its settings dialog becomes Page options (a new header button) with only the page's own settings: thumbnail startup check, lazy loading, items per page, grid columns, infinite scrolling, debug mode and API timeout. Save and Reset keep every other stored key, so the Appearance values System saves survive (S13-13). Generate Thumbnails, Clear cache, Rescan and Find duplicates leave the page; Page options links System › Maintenance and Sources & scans (rows B21–B28). * refactor(autotag): manage tagging models in System In both auto-tag dialogs a model that is not downloaded shows a Manage in System link to System › Auto-tagging (a new tab) instead of a Download button, and the gallery's Unload Model becomes the same link. The legacy download dialog and the download, cancel and unload methods go (rows B29–B31); the batch and review stay. A test hook marks a fake model loaded. * docs(system): name System as the home for settings and maintenance README and TESTING.md send settings, maintenance, backups, diagnostics, the gallery's tools and auto-tag model downloads to their System section and name each legacy page's Page options. * fix(gallery): keep the default view in Page options and save viewer choices alone System › Appearance's Default layout is the workspace's own key, so the legacy gallery's Default View Mode stays a page option instead of losing its only control. The viewer's mute and loop buttons remember just that choice instead of saving the Page options dialog's unfilled inputs over the stored page options. The pending-thumbnails toast names System › Sources & scans. * test(legacy): check each System link sends no request in its own test The no-request check runs inside each link's test, so a slow page no longer times out the whole table, and it ignores lazy thumbnail loads the layout starts on its own. * test(legacy): a lazy thumbnail load is not a System link's request * fix(gallery): let the header buttons wrap on a narrow screen With Page options beside Settings the header no longer fits a phone's width; it wrapped off the page and covered the Page options dialog in the touch runs. * feat(gallery): remove Default View Mode from Page options (#330) System > Appearance's Default layout is the one default-layout control, as the S13 design's G5 says. The legacy gallery's Page options no longer show or save a default view and Reset no longer resets it. A stored legacy defaultViewMode still picks the view the gallery opens in (grid otherwise), and Save and Reset keep that key for the workspace-preferences migration.
3055 lines
159 KiB
JavaScript
3055 lines
159 KiB
JavaScript
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';
|
||
// The page-only settings Page options edits and resets (#330 S13-13); System › Appearance owns the others.
|
||
// The default view is not one of them: System › Appearance's Default layout is its one control (S13 G5).
|
||
// A stored legacy defaultViewMode still picks the view this page opens in (applySettings), else grid.
|
||
static PAGE_OPTION_KEYS = Object.freeze(['checkThumbnailsAtStartup', 'lazyLoading', 'defaultLimit',
|
||
'gridColumns', 'infiniteScroll', 'debugMode', 'apiTimeout']);
|
||
|
||
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;
|
||
|
||
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();
|
||
|
||
// 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('pageOptionsBtn').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('galleryScroll').addEventListener('scroll', () => this.maybeLoadMore(), { passive: true });
|
||
|
||
// Close modal on outside click
|
||
document.getElementById('settingsModal').addEventListener('click', (e) => {
|
||
if (e.target.id === 'settingsModal') {
|
||
this.hideSettings();
|
||
}
|
||
});
|
||
|
||
// 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());
|
||
|
||
// 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', '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 ? '🔇' : '🔊';
|
||
|
||
this.storeSetting('videoMute', video.muted);
|
||
});
|
||
|
||
// Loop toggle
|
||
loopBtn.addEventListener('click', () => {
|
||
video.loop = !video.loop;
|
||
loopBtn.style.opacity = video.loop ? '1' : '0.5';
|
||
|
||
this.storeSetting('videoLoop', video.loop);
|
||
});
|
||
|
||
// 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 = '';
|
||
}
|
||
|
||
/** Page options: only the page's own settings (System › Appearance owns the rest of `gallerySettings`). */
|
||
loadSettingsFromStorage() {
|
||
const settings = this.getSettings();
|
||
document.getElementById('checkThumbnailsAtStartup').checked = settings.checkThumbnailsAtStartup;
|
||
document.getElementById('lazyLoadingToggle').checked = settings.lazyLoading;
|
||
document.getElementById('defaultLimitSelect').value = settings.defaultLimit;
|
||
document.getElementById('gridColumnsSelect').value = settings.gridColumns;
|
||
document.getElementById('infiniteScrollToggle').checked = settings.infiniteScroll;
|
||
document.getElementById('debugModeToggle').checked = settings.debugMode;
|
||
document.getElementById('apiTimeoutInput').value = settings.apiTimeout;
|
||
}
|
||
|
||
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 (System › Appearance, #249 System S3), so a Save starts from what is
|
||
// stored and keeps every key this dialog does not edit.
|
||
const settings = {
|
||
...this.getSettings(),
|
||
checkThumbnailsAtStartup: document.getElementById('checkThumbnailsAtStartup').checked,
|
||
lazyLoading: document.getElementById('lazyLoadingToggle').checked,
|
||
defaultLimit: parseInt(document.getElementById('defaultLimitSelect').value),
|
||
gridColumns: parseInt(document.getElementById('gridColumnsSelect').value),
|
||
infiniteScroll: document.getElementById('infiniteScrollToggle').checked,
|
||
debugMode: document.getElementById('debugModeToggle').checked,
|
||
apiTimeout: parseInt(document.getElementById('apiTimeoutInput').value),
|
||
};
|
||
|
||
try {
|
||
localStorage.setItem('gallerySettings', JSON.stringify(settings));
|
||
this.showNotification('Page options saved', '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');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Remembers one viewer choice (mute, loop) in the shared `gallerySettings`, keeping every other key; unlike
|
||
* Save it never reads the Page options dialog.
|
||
*/
|
||
storeSetting(key, value) {
|
||
try {
|
||
localStorage.setItem('gallerySettings', JSON.stringify({ ...this.getSettings(), [key]: value }));
|
||
} catch (e) {
|
||
console.warn('Failed to remember the viewer setting:', e);
|
||
}
|
||
}
|
||
|
||
/** Resets only the Page options keys to their defaults; every other stored key stays (#330 S13-13). */
|
||
resetSettings() {
|
||
if (!confirm('Reset these page options to their defaults?')) return;
|
||
const defaults = GallerySettings.DEFAULT_SETTINGS;
|
||
const settings = { ...this.getSettings() };
|
||
for (const key of GalleryManager.PAGE_OPTION_KEYS) settings[key] = defaults[key];
|
||
try {
|
||
localStorage.setItem('gallerySettings', JSON.stringify(settings));
|
||
} catch (e) {
|
||
console.error('Failed to reset page options:', e);
|
||
this.showNotification('Failed to reset page options', 'error');
|
||
return;
|
||
}
|
||
this.loadSettingsFromStorage();
|
||
this.applySettings(settings);
|
||
this.showNotification('Page options 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');
|
||
}
|
||
|
||
/** 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();
|
||
},
|
||
};
|
||
}
|
||
|
||
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 Page options.
|
||
</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,
|
||
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 = '';
|
||
// Download lives in System › Auto-tagging (#330); the batch refuses a model that is not downloaded.
|
||
const link = document.createElement('a');
|
||
link.href = 'workspace#/system/tagging';
|
||
link.target = '_blank';
|
||
link.rel = 'noopener';
|
||
link.title = 'Opens System › Auto-tagging in a new tab';
|
||
link.className = 'px-3 py-1 bg-pm-accent hover:bg-pm-accent-hover text-pm text-xs rounded transition-colors';
|
||
link.textContent = 'Manage in System';
|
||
el.appendChild(link);
|
||
}
|
||
}
|
||
|
||
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 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);
|
||
|
||
// The server sends `type` frames, as the admin page reads them (#328).
|
||
if (data.type === 'progress') {
|
||
document.getElementById('autoTagCurrentFile').textContent = data.status || 'Processing...';
|
||
document.getElementById('autoTagProgressPercent').textContent = `${data.progress || 0}%`;
|
||
document.getElementById('autoTagProgressBar').style.width = `${data.progress || 0}%`;
|
||
if (data.processed !== undefined) {
|
||
document.getElementById('autoTagProcessed').textContent = data.processed;
|
||
document.getElementById('autoTagApplied').textContent = data.tagged || 0;
|
||
document.getElementById('autoTagSkipped').textContent = data.skipped || 0;
|
||
}
|
||
} else if (data.type === 'complete') {
|
||
this.autoTagState.eventSource.close();
|
||
this.hideModal("autoTagProgressModal");
|
||
this.showNotification(ImageHelpers.autotagCompleteMessage(data.tagged), 'success');
|
||
this.loadImages(); // Refresh the gallery
|
||
} else if (data.type === 'error') {
|
||
this.autoTagState.eventSource.close();
|
||
this.hideModal("autoTagProgressModal");
|
||
this.showNotification(`Auto tag error: ${data.message}`, 'error');
|
||
} else if (data.type === 'cancelled') {
|
||
// Cancelled in Prompt Manager's Activity (#379): as its own cancel.
|
||
this.autoTagState.eventSource.close();
|
||
this.hideModal("autoTagProgressModal");
|
||
this.showNotification('Auto tagging cancelled', 'info');
|
||
}
|
||
};
|
||
|
||
this.autoTagState.eventSource.onerror = (error) => {
|
||
this.autoTagState.eventSource.close();
|
||
this.hideModal("autoTagProgressModal");
|
||
this.showNotification((error && error.serverMessage) || '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;
|
||
this.autoTagState.reviewImage = null;
|
||
this.autoTagState.reviewSha256 = 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.
|
||
// The gallery review always suggests (it never skipped tagged images, #379).
|
||
const requestBody = ImageHelpers.autotagSingleBody(image, { ...this.autoTagState, existing: 'merge' });
|
||
|
||
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.reviewImage = image;
|
||
this.autoTagState.reviewSha256 = data.sha256 || 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() {
|
||
// #379: the reviewed tags go to this image (linked or not), never its prompt; an empty
|
||
// list still marks the image as reviewed by a tagging model.
|
||
if (!this.autoTagState.reviewSha256) {
|
||
this.skipReviewImage();
|
||
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(ImageHelpers.autotagApplyBody(
|
||
this.autoTagState.reviewImage, this.autoTagState.reviewSha256, 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. Generate the rest from System › Sources & scans.`,
|
||
'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 = '';
|
||
}
|