diff --git a/database/operations.py b/database/operations.py index 28f6004..f98a3b4 100644 --- a/database/operations.py +++ b/database/operations.py @@ -552,6 +552,7 @@ class PromptDatabase: old_name = old_name.strip() new_name = new_name.strip() affected = 0 + skipped = 0 with self.model.get_connection() as conn: cursor = conn.execute( @@ -571,12 +572,14 @@ class PromptDatabase: (json.dumps(tags), datetime.datetime.now(datetime.timezone.utc).isoformat(), row['id']) ) affected += 1 - except (json.JSONDecodeError, TypeError): + except (json.JSONDecodeError, TypeError) as e: + skipped += 1 + self.logger.warning(f"Skipped prompt {row['id']} during tag rename: {e}") continue conn.commit() - self.logger.info(f"Renamed tag '{old_name}' -> '{new_name}' in {affected} prompts") - return {'success': True, 'affected_count': affected} + self.logger.info(f"Renamed tag '{old_name}' -> '{new_name}' in {affected} prompts (skipped {skipped})") + return {'success': True, 'affected_count': affected, 'skipped_count': skipped} def delete_tag_all_prompts(self, tag_name: str) -> Dict[str, Any]: """ @@ -593,6 +596,7 @@ class PromptDatabase: tag_name = tag_name.strip() affected = 0 + skipped = 0 with self.model.get_connection() as conn: cursor = conn.execute( @@ -610,12 +614,14 @@ class PromptDatabase: (json.dumps(tags), datetime.datetime.now(datetime.timezone.utc).isoformat(), row['id']) ) affected += 1 - except (json.JSONDecodeError, TypeError): + except (json.JSONDecodeError, TypeError) as e: + skipped += 1 + self.logger.warning(f"Skipped prompt {row['id']} during tag delete: {e}") continue conn.commit() - self.logger.info(f"Deleted tag '{tag_name}' from {affected} prompts") - return {'success': True, 'affected_count': affected} + self.logger.info(f"Deleted tag '{tag_name}' from {affected} prompts (skipped {skipped})") + return {'success': True, 'affected_count': affected, 'skipped_count': skipped} def merge_tags(self, source_tags: List[str], target_tag: str) -> Dict[str, Any]: """ @@ -637,6 +643,7 @@ class PromptDatabase: source_tags = [t.strip() for t in source_tags if t.strip()] affected = 0 tags_merged = 0 + skipped = 0 with self.model.get_connection() as conn: for src_tag in source_tags: @@ -658,15 +665,17 @@ class PromptDatabase: (json.dumps(tags), datetime.datetime.now(datetime.timezone.utc).isoformat(), row['id']) ) src_affected += 1 - except (json.JSONDecodeError, TypeError): + except (json.JSONDecodeError, TypeError) as e: + skipped += 1 + self.logger.warning(f"Skipped prompt {row['id']} during tag merge: {e}") continue if src_affected > 0: tags_merged += 1 affected += src_affected conn.commit() - self.logger.info(f"Merged {tags_merged} tags into '{target_tag}', affected {affected} prompts") - return {'success': True, 'affected_count': affected, 'tags_merged': tags_merged} + self.logger.info(f"Merged {tags_merged} tags into '{target_tag}', affected {affected} prompts (skipped {skipped})") + return {'success': True, 'affected_count': affected, 'tags_merged': tags_merged, 'skipped_count': skipped} def get_untagged_prompts_count(self) -> int: """ @@ -1085,7 +1094,8 @@ class PromptDatabase: """ SELECT gi.*, p.text as prompt_text, p.tags as prompt_tags FROM generated_images gi - LEFT JOIN prompts p ON gi.prompt_id = p.id + INNER JOIN prompts p ON gi.prompt_id = p.id + WHERE gi.image_path IS NOT NULL AND gi.image_path != '' ORDER BY gi.generation_time DESC """ ) diff --git a/py/api.py b/py/api.py index 5fdb69d..87e09c2 100644 --- a/py/api.py +++ b/py/api.py @@ -820,8 +820,13 @@ class PromptManagerAPI: async def get_tags_stats(self, request): """Get tags with usage counts, search, sort, and pagination.""" try: - limit = int(request.query.get("limit", 50)) - offset = int(request.query.get("offset", 0)) + try: + limit = int(request.query.get("limit", 50)) + offset = int(request.query.get("offset", 0)) + except (ValueError, TypeError): + return web.json_response( + {"success": False, "error": "Invalid limit or offset parameter"}, status=400 + ) search = request.query.get("search", "").strip() or None sort = request.query.get("sort", "alpha_asc") @@ -895,8 +900,13 @@ class PromptManagerAPI: status=400, ) - limit = int(request.query.get("limit", 20)) - offset = int(request.query.get("offset", 0)) + try: + limit = int(request.query.get("limit", 20)) + offset = int(request.query.get("offset", 0)) + except (ValueError, TypeError): + return web.json_response( + {"success": False, "error": "Invalid limit or offset parameter"}, status=400 + ) result = self.db.get_prompts_by_tags([tag_name], 'and', limit, offset) self._enrich_prompt_images(result['prompts']) @@ -925,8 +935,13 @@ class PromptManagerAPI: untagged = request.query.get("untagged", "").lower() == "true" if untagged: - limit = int(request.query.get("limit", 20)) - offset = int(request.query.get("offset", 0)) + try: + limit = int(request.query.get("limit", 20)) + offset = int(request.query.get("offset", 0)) + except (ValueError, TypeError): + return web.json_response( + {"success": False, "error": "Invalid limit or offset parameter"}, status=400 + ) result = self.db.get_untagged_prompts(limit, offset) self._enrich_prompt_images(result['prompts']) return web.json_response({ @@ -954,8 +969,13 @@ class PromptManagerAPI: if mode not in ("and", "or"): mode = "and" - limit = int(request.query.get("limit", 20)) - offset = int(request.query.get("offset", 0)) + try: + limit = int(request.query.get("limit", 20)) + offset = int(request.query.get("offset", 0)) + except (ValueError, TypeError): + return web.json_response( + {"success": False, "error": "Invalid limit or offset parameter"}, status=400 + ) result = self.db.get_prompts_by_tags(tags_list, mode, limit, offset) self._enrich_prompt_images(result['prompts']) @@ -989,7 +1009,12 @@ class PromptManagerAPI: {"success": False, "error": "Tag name required"}, status=400 ) - body = await request.json() + try: + body = await request.json() + except Exception: + return web.json_response( + {"success": False, "error": "Invalid JSON body"}, status=400 + ) new_name = body.get("new_name", "").strip() if not new_name: return web.json_response( @@ -997,12 +1022,16 @@ class PromptManagerAPI: ) result = self.db.rename_tag_all_prompts(tag_name, new_name) - return web.json_response({ + resp = { "success": True, "old_name": tag_name, "new_name": new_name, "affected_count": result['affected_count'] - }) + } + if result.get('skipped_count', 0) > 0: + resp['skipped_count'] = result['skipped_count'] + resp['warning'] = f"{result['skipped_count']} prompt(s) had corrupted tag data and were skipped" + return web.json_response(resp) except Exception as e: self.logger.error(f"Rename tag error: {e}", exc_info=True) return web.json_response( @@ -1020,11 +1049,15 @@ class PromptManagerAPI: ) result = self.db.delete_tag_all_prompts(tag_name) - return web.json_response({ + resp = { "success": True, "tag_name": tag_name, "affected_count": result['affected_count'] - }) + } + if result.get('skipped_count', 0) > 0: + resp['skipped_count'] = result['skipped_count'] + resp['warning'] = f"{result['skipped_count']} prompt(s) had corrupted tag data and were skipped" + return web.json_response(resp) except Exception as e: self.logger.error(f"Delete tag error: {e}", exc_info=True) return web.json_response( @@ -1034,7 +1067,12 @@ class PromptManagerAPI: async def merge_tags_endpoint(self, request): """Merge source tags into a target tag.""" try: - body = await request.json() + try: + body = await request.json() + except Exception: + return web.json_response( + {"success": False, "error": "Invalid JSON body"}, status=400 + ) source_tags = body.get("source_tags", []) target_tag = body.get("target_tag", "").strip() @@ -1048,12 +1086,16 @@ class PromptManagerAPI: ) result = self.db.merge_tags(source_tags, target_tag) - return web.json_response({ + resp = { "success": True, "target_tag": target_tag, "affected_count": result['affected_count'], "tags_merged": result['tags_merged'] - }) + } + if result.get('skipped_count', 0) > 0: + resp['skipped_count'] = result['skipped_count'] + resp['warning'] = f"{result['skipped_count']} prompt(s) had corrupted tag data and were skipped" + return web.json_response(resp) except Exception as e: self.logger.error(f"Merge tags error: {e}", exc_info=True) return web.json_response( @@ -4878,6 +4920,10 @@ class PromptManagerAPI: tagged = 0 skipped = 0 errors = 0 + tagged_prompt_ids = set() # Track prompts already tagged this run + + import time as _time + last_update_time = _time.monotonic() for i, image_data in enumerate(images): image_path = image_data.get('image_path') @@ -4887,9 +4933,26 @@ class PromptManagerAPI: skipped += 1 continue + # Skip if we already tagged this prompt during this run + if prompt_id in tagged_prompt_ids: + skipped += 1 + now = _time.monotonic() + if (now - last_update_time) >= 0.5 or i == total_files - 1: + progress = 10 + int((i + 1) / total_files * 85) + yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Skipping {i+1}/{total_files} (prompt already processed)...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n" + await asyncio.sleep(0.01) + last_update_time = now + continue + # Check if file exists if not Path(image_path).exists(): skipped += 1 + now = _time.monotonic() + if (now - last_update_time) >= 0.5 or i == total_files - 1: + progress = 10 + int((i + 1) / total_files * 85) + yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Skipping {i+1}/{total_files} (file missing)...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n" + await asyncio.sleep(0.01) + last_update_time = now continue # Check if image already has real tags (skip_tagged option) @@ -4900,11 +4963,14 @@ class PromptManagerAPI: # Filter out "auto-scanned" - it's not a real tag real_tags = [t for t in prompt_tags if t != 'auto-scanned'] if real_tags: + tagged_prompt_ids.add(prompt_id) # Don't re-check other images for this prompt skipped += 1 - if i % 5 == 0 or i == total_files - 1: + now = _time.monotonic() + if (now - last_update_time) >= 0.5 or i == total_files - 1: progress = 10 + int((i + 1) / total_files * 85) yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Skipping {i+1}/{total_files} (already tagged)...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n" await asyncio.sleep(0.01) + last_update_time = now continue try: @@ -4917,7 +4983,7 @@ class PromptManagerAPI: processed += 1 if tags: - # Get existing prompt + # Get existing prompt (live read, not snapshot) existing_prompt = self.db.get_prompt_by_id(prompt_id) if existing_prompt: existing_tags = existing_prompt.get('tags', []) @@ -4939,10 +5005,14 @@ class PromptManagerAPI: else: skipped += 1 - if i % 5 == 0 or i == total_files - 1: - progress = 10 + int((i + 1) / total_files * 85) - yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Processing {i+1}/{total_files}...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n" - await asyncio.sleep(0.01) + # Mark this prompt as done so other images for it are skipped + tagged_prompt_ids.add(prompt_id) + + # Always send progress after LLM inference (each call is slow) + progress = 10 + int((i + 1) / total_files * 85) + yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Processing {i+1}/{total_files}...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n" + await asyncio.sleep(0.01) + last_update_time = _time.monotonic() except Exception as img_err: self.logger.error(f"Error processing {image_path}: {img_err}") diff --git a/pyproject.toml b/pyproject.toml index a5aa150..5e151f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "promptmanager" description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite." -version = "3.0.31" +version = "3.0.32" license = {file = "LICENSE"} dependencies = ["# Core dependencies for PromptManager", "# Note: Most dependencies are already included with ComfyUI", "# Already included with Python standard library:", "# - sqlite3", "# - hashlib", "# - json", "# - datetime", "# - os", "# - typing", "# - threading", "# - uuid", "# Required for gallery functionality:", "watchdog>=2.1.0 # For file system monitoring", "Pillow>=8.0.0 # For image metadata extraction (usually included with ComfyUI)", "# Optional dependencies for enhanced search functionality:", "# fuzzywuzzy[speedup]>=0.18.0 # For fuzzy string matching (optional)", "# sqlalchemy>=1.4.0 # For advanced ORM features (optional)", "# Development dependencies (optional):", "# pytest>=6.0.0 # For running tests", "# black>=22.0.0 # For code formatting", "# flake8>=4.0.0 # For linting", "# mypy>=0.910 # For type checking"] diff --git a/utils/comfyui_integration.py b/utils/comfyui_integration.py index 5c76ea1..5aa334a 100644 --- a/utils/comfyui_integration.py +++ b/utils/comfyui_integration.py @@ -15,8 +15,7 @@ Key features: The integration works by: 1. PromptManager nodes register their prompts during execution 2. SaveImage node is patched to include registered prompts in metadata -3. PromptManager class_type is changed to CLIPTextEncode for parser compatibility -4. Standard tools can then extract prompts from the generated images +3. Standard tools can then extract prompts from the generated images Typical usage: from utils.comfyui_integration import get_comfyui_integration @@ -61,7 +60,6 @@ class ComfyUIMetadataIntegration: Key responsibilities: - Register prompts from PromptManager nodes during execution - Patch SaveImage to include PromptManager prompts in metadata - - Convert PromptManager class_type to CLIPTextEncode for compatibility - Manage prompt lifecycle and cleanup """ @@ -184,15 +182,16 @@ class ComfyUIMetadataIntegration: This method modifies ComfyUI's SaveImage.save_images method to automatically include PromptManager prompts in the image metadata. The patching: - + 1. Wraps the original save_images method 2. Retrieves current PromptManager prompt text - 3. Modifies the workflow data to include the prompt - 4. Changes PromptManager class_type to CLIPTextEncode for compatibility - 5. Calls the original method with modified data - - The patching is designed to be minimally invasive and maintain full - compatibility with existing ComfyUI functionality. + 3. Updates the text input in PromptManager nodes to reflect actual prompt + 4. Calls the original method with the updated data + + NOTE: We intentionally do NOT change class_type to CLIPTextEncode anymore. + That approach was corrupting saved workflows - when users saved and reloaded + workflows, ComfyUI would instantiate CLIPTextEncode instead of PromptManager, + causing errors with prepend_text/append_text inputs. """ try: import nodes @@ -222,41 +221,18 @@ class ComfyUIMetadataIntegration: if not isinstance(prompt, dict): prompt = {} - # Find PromptManager nodes and fix them for standard parser compatibility - prompt_updated = False + # Find PromptManager nodes and ensure prompt text is captured + # NOTE: We do NOT change class_type anymore - that was corrupting saved workflows + # when users reload them. PromptManager stays as PromptManager. for node_id, node_data in prompt.items(): if isinstance(node_data, dict): class_type = node_data.get('class_type', '') if 'promptmanager' in class_type.lower(): - # Update the inputs to include our actual prompt text + # Ensure the text input reflects the actual prompt used if 'inputs' not in node_data: node_data['inputs'] = {} node_data['inputs']['text'] = current_prompt_text - - # SIMPLE FIX: Change class_type to CLIPTextEncode for standard parser compatibility - # Keep original class_type in metadata for reference - if '_meta' not in node_data: - node_data['_meta'] = {} - node_data['_meta']['original_class_type'] = class_type - node_data['class_type'] = 'CLIPTextEncode' - - prompt_updated = True - integration.logger.debug(f"Fixed PromptManager node {node_id} - changed class_type to CLIPTextEncode for compatibility") - - # If no PromptManager nodes found, add a standalone one - if not prompt_updated: - virtual_node_id = "promptmanager_text" - prompt[virtual_node_id] = { - "class_type": "CLIPTextEncode", # Use CLIPTextEncode for compatibility - "inputs": { - "text": current_prompt_text - }, - "_meta": { - "original_class_type": "PromptManager", - "virtual": True - } - } - integration.logger.debug("Added standalone CLIPTextEncode node with PromptManager text") + integration.logger.debug(f"Updated PromptManager node {node_id} with prompt text") # Call original method with potentially modified prompt return original_save_images(self_node, images, filename_prefix, prompt, extra_pnginfo) diff --git a/web/admin.html b/web/admin.html index 0e42694..ab5614f 100644 --- a/web/admin.html +++ b/web/admin.html @@ -461,8 +461,10 @@
-
@@ -480,8 +482,13 @@
- @@ -1487,6 +1494,10 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr const tagsPage = document.getElementById('tagsPageView'); if (dashboard) dashboard.classList.remove('hidden'); if (tagsPage) tagsPage.classList.add('hidden'); + if (this.tagsPage) { + this.tagsPage.destroy(); + this.tagsPage = null; + } } showTagsPage() { diff --git a/web/js/tags-page.js b/web/js/tags-page.js index 584317c..60fb8a1 100644 --- a/web/js/tags-page.js +++ b/web/js/tags-page.js @@ -40,6 +40,10 @@ class TagsPageManager { // Context menu state this.contextMenuTag = null; + // Document listener refs for cleanup + this._onDocClick = null; + this._onDocKeydown = null; + this.bindEvents(); this.initInfiniteScroll(); this.buildContextMenu(); @@ -105,8 +109,7 @@ class TagsPageManager { }); if (this.tagSearch) params.set('search', this.tagSearch); - const resp = await fetch(`/prompt_manager/tags/stats?${params}`); - const data = await resp.json(); + const data = await this.fetchJson(`/prompt_manager/tags/stats?${params}`); if (data.success) { this.tags = data.tags; @@ -127,8 +130,17 @@ class TagsPageManager { } } catch (err) { console.error('Failed to load tags:', err); + const container = document.getElementById('tagsList'); + if (container) { + container.replaceChildren(); + const errDiv = document.createElement('div'); + errDiv.className = 'text-center py-8 text-red-400 text-sm'; + errDiv.textContent = 'Failed to load tags. Try refreshing.'; + container.appendChild(errDiv); + } } finally { this.isLoadingTags = false; + requestAnimationFrame(() => this.refreshTagObserver()); } } @@ -136,8 +148,8 @@ class TagsPageManager { if (this.isLoadingTags || !this.hasMoreTags) return; this.isLoadingTags = true; - const loader = document.getElementById('tagsLoader'); - if (loader) loader.classList.remove('hidden'); + const spinner = document.querySelector('#tagsLoader .tags-loader-spinner'); + if (spinner) spinner.classList.remove('hidden'); try { const params = new URLSearchParams({ @@ -147,24 +159,37 @@ class TagsPageManager { }); if (this.tagSearch) params.set('search', this.tagSearch); - const resp = await fetch(`/prompt_manager/tags/stats?${params}`); - const data = await resp.json(); + const data = await this.fetchJson(`/prompt_manager/tags/stats?${params}`); if (data.success) { - this.tags = this.tags.concat(data.tags); + const newTags = data.tags; + this.tags = this.tags.concat(newTags); this.hasMoreTags = data.pagination.has_more; this.tagOffset += this.tagLimit; this.maxTagCount = Math.max(1, ...this.tags.map(t => t.count)); - this.renderTagsList(); + this.appendTagElements(newTags); } } catch (err) { console.error('Failed to load more tags:', err); + this.hasMoreTags = false; } finally { this.isLoadingTags = false; - if (loader) loader.classList.add('hidden'); + if (spinner) spinner.classList.add('hidden'); + requestAnimationFrame(() => this.refreshTagObserver()); } } + appendTagElements(newTags) { + const container = document.getElementById('tagsList'); + if (!container || newTags.length === 0) return; + + const fragment = document.createDocumentFragment(); + newTags.forEach(tag => { + fragment.appendChild(this.createTagElement(tag)); + }); + container.appendChild(fragment); + } + renderTagsList() { const container = document.getElementById('tagsList'); if (!container) return; @@ -287,13 +312,15 @@ class TagsPageManager { }); menu.replaceChildren(fragment); - // Close on click outside - document.addEventListener('click', (e) => { + // Close on click outside (store refs for cleanup) + this._onDocClick = (e) => { if (!menu.contains(e.target)) this.hideContextMenu(); - }); - document.addEventListener('keydown', (e) => { + }; + this._onDocKeydown = (e) => { if (e.key === 'Escape') this.hideContextMenu(); - }); + }; + document.addEventListener('click', this._onDocClick); + document.addEventListener('keydown', this._onDocKeydown); } showContextMenu(e, tagName) { @@ -329,14 +356,15 @@ class TagsPageManager { if (!newName || newName.trim() === '' || newName.trim() === tagName) return; try { - const resp = await fetch(`/prompt_manager/tags/${encodeURIComponent(tagName)}`, { + const data = await this.fetchJson(`/prompt_manager/tags/${encodeURIComponent(tagName)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ new_name: newName.trim() }) }); - const data = await resp.json(); if (data.success) { - this.admin.showNotification(`Renamed "${tagName}" → "${newName.trim()}" (${data.affected_count} prompts)`, 'success'); + let msg = `Renamed "${tagName}" → "${newName.trim()}" (${data.affected_count} prompts)`; + if (data.warning) msg += ` — ${data.warning}`; + this.admin.showNotification(msg, data.warning ? 'warning' : 'success'); // Update selectedTags if the renamed tag was selected const idx = this.selectedTags.indexOf(tagName); if (idx >= 0) this.selectedTags[idx] = newName.trim(); @@ -357,12 +385,13 @@ class TagsPageManager { if (!confirm(`Delete tag "${tagName}" from all prompts? This cannot be undone.`)) return; try { - const resp = await fetch(`/prompt_manager/tags/${encodeURIComponent(tagName)}`, { + const data = await this.fetchJson(`/prompt_manager/tags/${encodeURIComponent(tagName)}`, { method: 'DELETE' }); - const data = await resp.json(); if (data.success) { - this.admin.showNotification(`Deleted tag "${tagName}" from ${data.affected_count} prompts`, 'success'); + let msg = `Deleted tag "${tagName}" from ${data.affected_count} prompts`; + if (data.warning) msg += ` — ${data.warning}`; + this.admin.showNotification(msg, data.warning ? 'warning' : 'success'); this.selectedTags = this.selectedTags.filter(t => t !== tagName); this.updateUrlHash(); this.loadTagsList(); @@ -387,17 +416,15 @@ class TagsPageManager { if (!targetTag || targetTag.trim() === '' || targetTag.trim() === tagName) return; try { - const resp = await fetch('/prompt_manager/tags/merge', { + const data = await this.fetchJson('/prompt_manager/tags/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source_tags: [tagName], target_tag: targetTag.trim() }) }); - const data = await resp.json(); if (data.success) { - this.admin.showNotification( - `Merged "${tagName}" → "${targetTag.trim()}" (${data.affected_count} prompts)`, - 'success' - ); + let msg = `Merged "${tagName}" → "${targetTag.trim()}" (${data.affected_count} prompts)`; + if (data.warning) msg += ` — ${data.warning}`; + this.admin.showNotification(msg, data.warning ? 'warning' : 'success'); this.selectedTags = this.selectedTags.filter(t => t !== tagName); this.updateUrlHash(); this.loadTagsList(); @@ -426,6 +453,15 @@ class TagsPageManager { this.loadTagPrompts(); } + scrollToTag(name) { + const container = document.getElementById('tagsListContainer'); + if (!container) return; + const tagEl = container.querySelector(`[data-tag-name="${CSS.escape(name)}"]`); + if (tagEl) { + tagEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + } + toggleTagFilter(name) { const idx = this.selectedTags.indexOf(name); if (idx >= 0) { @@ -585,8 +621,7 @@ class TagsPageManager { url = `/prompt_manager/tags/filter?${params}`; } - const resp = await fetch(url); - const data = await resp.json(); + const data = await this.fetchJson(url); if (data.success) { this.prompts = data.prompts; @@ -595,6 +630,8 @@ class TagsPageManager { this.promptOffset = this.promptLimit; this.renderTagPrompts(); this.updatePromptCountBadge(); + this.updateLoadMoreButton(); + requestAnimationFrame(() => this.refreshPromptObserver()); } } catch (err) { console.error('Failed to load tag prompts:', err); @@ -614,8 +651,8 @@ class TagsPageManager { if (this.isLoadingPrompts || !this.hasMorePrompts) return; this.isLoadingPrompts = true; - const loader = document.getElementById('tagPromptsLoader'); - if (loader) loader.classList.remove('hidden'); + const spinner = document.querySelector('#tagPromptsLoader .prompts-loader-spinner'); + if (spinner) spinner.classList.remove('hidden'); try { const params = new URLSearchParams({ @@ -637,33 +674,77 @@ class TagsPageManager { url = `/prompt_manager/tags/filter?${params}`; } - const resp = await fetch(url); - const data = await resp.json(); + const data = await this.fetchJson(url); if (data.success) { + const startIndex = this.prompts.length; this.prompts = this.prompts.concat(data.prompts); this.hasMorePrompts = data.pagination.has_more; this.promptOffset += this.promptLimit; - this.renderTagPrompts(); + this.appendTagPrompts(data.prompts, startIndex); this.updatePromptCountBadge(); } } catch (err) { console.error('Failed to load more prompts:', err); + this.hasMorePrompts = false; } finally { this.isLoadingPrompts = false; - if (loader) loader.classList.add('hidden'); + if (spinner) spinner.classList.add('hidden'); + this.updateLoadMoreButton(); + requestAnimationFrame(() => this.refreshPromptObserver()); } } updatePromptCountBadge() { const badge = document.getElementById('tagPromptsCountBadge'); if (badge) { - badge.textContent = this.promptTotal > 0 - ? `${this.prompts.length} of ${this.promptTotal} prompts` - : ''; + if (this.promptTotal > 0) { + const remaining = this.promptTotal - this.prompts.length; + badge.textContent = remaining > 0 + ? `${this.prompts.length} of ${this.promptTotal} prompts — scroll for more` + : `${this.promptTotal} prompts`; + } else { + badge.textContent = ''; + } } } + updateLoadMoreButton() { + const btn = document.getElementById('loadMorePromptsBtn'); + if (!btn) return; + if (this.hasMorePrompts) { + const remaining = this.promptTotal - this.prompts.length; + btn.textContent = `Load more prompts (${remaining} remaining)`; + btn.classList.remove('hidden'); + } else { + btn.classList.add('hidden'); + } + } + + appendTagPrompts(newPrompts, startIndex) { + const grid = document.getElementById('tagPromptsGrid'); + if (!grid || newPrompts.length === 0) return; + + const fragment = document.createDocumentFragment(); + newPrompts.forEach((p, i) => { + const card = this.createPromptCard(p); + card.style.opacity = '0'; + card.style.transform = 'translateY(8px)'; + card.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + card.style.transitionDelay = (i % 9) * 30 + 'ms'; + fragment.appendChild(card); + }); + grid.appendChild(fragment); + + requestAnimationFrame(() => { + const cards = grid.querySelectorAll('.tag-prompt-card'); + for (let i = startIndex; i < cards.length; i++) { + cards[i].style.opacity = '1'; + cards[i].style.transform = 'translateY(0)'; + } + }); + } + renderTagPrompts() { const grid = document.getElementById('tagPromptsGrid'); if (!grid) return; @@ -810,15 +891,23 @@ class TagsPageManager { card.appendChild(metaRow); - // Tags + // Tags (clickable — selects tag on left panel) if (prompt.tags && prompt.tags.length > 0) { const tagsDiv = document.createElement('div'); tagsDiv.className = 'flex flex-wrap gap-1 mt-2'; - prompt.tags.forEach(tag => { + prompt.tags.forEach(tagName => { const tagSpan = document.createElement('span'); - const isActive = this.selectedTags.includes(tag); - tagSpan.className = `px-2 py-0.5 rounded text-xs ${isActive ? 'bg-green-600 text-white' : 'bg-gray-700 text-gray-300'}`; - tagSpan.textContent = tag; + const isActive = this.selectedTags.includes(tagName); + tagSpan.className = `px-2 py-0.5 rounded text-xs cursor-pointer transition-colors ${ + isActive + ? 'bg-green-600 text-white hover:bg-green-500' + : 'bg-gray-700 text-gray-300 hover:bg-green-600/40 hover:text-green-200' + }`; + tagSpan.textContent = tagName; + tagSpan.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleTagFilter(tagName); + }); tagsDiv.appendChild(tagSpan); }); card.appendChild(tagsDiv); @@ -836,13 +925,16 @@ class TagsPageManager { // If there might be more images, load them all if (prompt.image_count > images.length) { try { - const resp = await fetch(`/prompt_manager/prompts/${prompt.id}/images`); - const data = await resp.json(); + const data = await this.fetchJson(`/prompt_manager/prompts/${prompt.id}/images`); if (data.success && data.images) { images = data.images; } } catch (err) { console.error('Failed to load all images:', err); + this.admin.showNotification( + `Showing ${images.length} of ${prompt.image_count} images — full load failed`, + 'warning' + ); } } @@ -897,8 +989,10 @@ class TagsPageManager { clearPrompts() { this.prompts = []; this.promptTotal = 0; + this.hasMorePrompts = false; const grid = document.getElementById('tagPromptsGrid'); if (grid) grid.replaceChildren(); + this.updateLoadMoreButton(); const emptyState = document.getElementById('tagPromptsEmpty'); if (emptyState) emptyState.classList.remove('hidden'); this.updatePromptCountBadge(); @@ -930,6 +1024,24 @@ class TagsPageManager { } } + refreshPromptObserver() { + if (!this.promptObserver || !this.hasMorePrompts) return; + const loader = document.getElementById('tagPromptsLoader'); + if (loader) { + this.promptObserver.unobserve(loader); + this.promptObserver.observe(loader); + } + } + + refreshTagObserver() { + if (!this.tagObserver || !this.hasMoreTags) return; + const loader = document.getElementById('tagsLoader'); + if (loader) { + this.tagObserver.unobserve(loader); + this.tagObserver.observe(loader); + } + } + destroyInfiniteScroll() { if (this.tagObserver) { this.tagObserver.disconnect(); @@ -970,16 +1082,40 @@ class TagsPageManager { const orBtn = document.getElementById('filterModeOr'); if (andBtn) andBtn.addEventListener('click', () => this.setFilterMode('and')); if (orBtn) orBtn.addEventListener('click', () => this.setFilterMode('or')); + + // Load More button (fallback for infinite scroll) + const loadMoreBtn = document.getElementById('loadMorePromptsBtn'); + if (loadMoreBtn) { + loadMoreBtn.addEventListener('click', () => this.loadMoreTagPrompts()); + } } destroy() { this.destroyInfiniteScroll(); this.hideContextMenu(); clearTimeout(this.searchTimer); + if (this._onDocClick) { + document.removeEventListener('click', this._onDocClick); + this._onDocClick = null; + } + if (this._onDocKeydown) { + document.removeEventListener('keydown', this._onDocKeydown); + this._onDocKeydown = null; + } } // ── Helpers ─────────────────────────────────────────────── + async fetchJson(url, options = {}) { + const resp = await fetch(url, options); + if (!resp.ok) { + let errorMsg; + try { errorMsg = (await resp.json()).error; } catch { errorMsg = resp.statusText; } + throw new Error(`Server error ${resp.status}: ${errorMsg}`); + } + return resp.json(); + } + getThumbnailUrl(image) { if (image.thumbnail_url) return image.thumbnail_url; if (image.url) return image.url;