From 760b2a0f482d5173925b7cfc0a11752b476bb408 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 3 Feb 2026 18:40:58 -0800 Subject: [PATCH] feat(tags): add Phase 2 tag management enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bulk tag operations: rename, delete, merge across all prompts - Right-click context menu on tags (rename/delete/merge) - Click prompt card → dashboard search, click thumbnail → ViewerJS - Usage bars, prompt count badge, staggered card animations - URL-synced tag selection (#/tags/landscape,portrait?mode=or) - Untagged prompts filter with [untagged] entry --- database/operations.py | 343 +++++++++++++- py/api.py | 306 +++++++++++++ web/admin.html | 123 ++++- web/js/tags-page.js | 994 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1764 insertions(+), 2 deletions(-) create mode 100644 web/js/tags-page.js diff --git a/database/operations.py b/database/operations.py index 34d58c8..28f6004 100644 --- a/database/operations.py +++ b/database/operations.py @@ -389,7 +389,348 @@ class PromptDatabase: continue return sorted(list(all_tags)) - + + def get_tags_with_counts( + self, + limit: int = 50, + offset: int = 0, + search: Optional[str] = None, + sort: str = "alpha_asc" + ) -> Dict[str, Any]: + """ + Get all unique tags with their usage counts, with search/sort/pagination. + + Args: + limit: Maximum number of tags to return + offset: Number of tags to skip + search: Optional case-insensitive substring filter + sort: Sort order - alpha_asc, alpha_desc, count_desc, count_asc + + Returns: + Dict with tags list, total count, and pagination info + """ + tag_counts: Dict[str, int] = {} + + with self.model.get_connection() as conn: + cursor = conn.execute( + "SELECT tags FROM prompts WHERE tags IS NOT NULL AND tags != '' AND tags != '[]'" + ) + for row in cursor.fetchall(): + try: + tags = json.loads(row['tags']) + if isinstance(tags, list): + for tag in tags: + if isinstance(tag, str) and tag.strip(): + tag_counts[tag.strip()] = tag_counts.get(tag.strip(), 0) + 1 + elif isinstance(tags, str): + for t in tags.split(','): + t = t.strip() + if t: + tag_counts[t] = tag_counts.get(t, 0) + 1 + except (json.JSONDecodeError, TypeError): + continue + + # Apply search filter + if search: + search_lower = search.lower() + tag_counts = {k: v for k, v in tag_counts.items() if search_lower in k.lower()} + + # Sort + if sort == "alpha_desc": + sorted_tags = sorted(tag_counts.items(), key=lambda x: x[0].lower(), reverse=True) + elif sort == "count_desc": + sorted_tags = sorted(tag_counts.items(), key=lambda x: (-x[1], x[0].lower())) + elif sort == "count_asc": + sorted_tags = sorted(tag_counts.items(), key=lambda x: (x[1], x[0].lower())) + else: # alpha_asc + sorted_tags = sorted(tag_counts.items(), key=lambda x: x[0].lower()) + + total = len(sorted_tags) + paginated = sorted_tags[offset:offset + limit] + + return { + 'tags': [{'name': name, 'count': count} for name, count in paginated], + 'total': total, + 'limit': limit, + 'offset': offset, + 'has_more': (offset + limit) < total + } + + def get_prompts_by_tags( + self, + tags: List[str], + mode: str = "and", + limit: int = 20, + offset: int = 0 + ) -> Dict[str, Any]: + """ + Get prompts that match the given tags with AND/OR filtering. + + Args: + tags: List of tag names to filter by + mode: 'and' (must have all tags) or 'or' (must have any tag) + limit: Maximum number of prompts to return + offset: Number of prompts to skip + + Returns: + Dict with prompts list (including preview images), total count, pagination + """ + if not tags: + return {'prompts': [], 'total': 0, 'limit': limit, 'offset': offset, 'has_more': False} + + with self.model.get_connection() as conn: + # Build tag filter conditions using quoted strings to prevent partial matches + tag_conditions = [] + params = [] + for tag in tags: + tag_conditions.append('tags LIKE ?') + params.append(f'%"{tag}"%') + + joiner = ' AND ' if mode == 'and' else ' OR ' + where_clause = f"tags IS NOT NULL AND ({joiner.join(tag_conditions)})" + + # Get total count + count_query = f"SELECT COUNT(*) as total FROM prompts WHERE {where_clause}" + cursor = conn.execute(count_query, params) + total = cursor.fetchone()['total'] + + # Get paginated results + data_query = f"SELECT * FROM prompts WHERE {where_clause} ORDER BY created_at DESC LIMIT ? OFFSET ?" + cursor = conn.execute(data_query, params + [limit, offset]) + rows = cursor.fetchall() + + prompts = [] + for row in rows: + prompt = self._row_to_dict(row) + + # Get first 3 images (lightweight - skip heavy JSON fields) + img_cursor = conn.execute( + """SELECT id, prompt_id, image_path, filename, generation_time, width, height, format + FROM generated_images + WHERE prompt_id = ? + ORDER BY generation_time DESC + LIMIT 3""", + (prompt['id'],) + ) + images = [dict(img_row) for img_row in img_cursor.fetchall()] + + # Get total image count + count_cursor = conn.execute( + "SELECT COUNT(*) as cnt FROM generated_images WHERE prompt_id = ?", + (prompt['id'],) + ) + image_count = count_cursor.fetchone()['cnt'] + + prompt['images'] = images + prompt['image_count'] = image_count + prompts.append(prompt) + + return { + 'prompts': prompts, + 'total': total, + 'limit': limit, + 'offset': offset, + 'has_more': (offset + limit) < total + } + + def rename_tag_all_prompts(self, old_name: str, new_name: str) -> Dict[str, Any]: + """ + Rename a tag across all prompts that use it. + + Args: + old_name: Current tag name + new_name: New tag name + + Returns: + Dict with success status and affected_count + """ + if not old_name or not old_name.strip(): + raise ValueError("Old tag name cannot be empty") + if not new_name or not new_name.strip(): + raise ValueError("New tag name cannot be empty") + + old_name = old_name.strip() + new_name = new_name.strip() + affected = 0 + + with self.model.get_connection() as conn: + cursor = conn.execute( + "SELECT id, tags FROM prompts WHERE tags LIKE ?", + (f'%"{old_name}"%',) + ) + for row in cursor.fetchall(): + try: + tags = json.loads(row['tags']) + if not isinstance(tags, list) or old_name not in tags: + continue + tags.remove(old_name) + if new_name not in tags: + tags.append(new_name) + conn.execute( + "UPDATE prompts SET tags = ?, updated_at = ? WHERE id = ?", + (json.dumps(tags), datetime.datetime.now(datetime.timezone.utc).isoformat(), row['id']) + ) + affected += 1 + except (json.JSONDecodeError, TypeError): + continue + conn.commit() + + self.logger.info(f"Renamed tag '{old_name}' -> '{new_name}' in {affected} prompts") + return {'success': True, 'affected_count': affected} + + def delete_tag_all_prompts(self, tag_name: str) -> Dict[str, Any]: + """ + Remove a tag from all prompts that use it. + + Args: + tag_name: Tag name to remove + + Returns: + Dict with success status and affected_count + """ + if not tag_name or not tag_name.strip(): + raise ValueError("Tag name cannot be empty") + + tag_name = tag_name.strip() + affected = 0 + + with self.model.get_connection() as conn: + cursor = conn.execute( + "SELECT id, tags FROM prompts WHERE tags LIKE ?", + (f'%"{tag_name}"%',) + ) + for row in cursor.fetchall(): + try: + tags = json.loads(row['tags']) + if not isinstance(tags, list) or tag_name not in tags: + continue + tags.remove(tag_name) + conn.execute( + "UPDATE prompts SET tags = ?, updated_at = ? WHERE id = ?", + (json.dumps(tags), datetime.datetime.now(datetime.timezone.utc).isoformat(), row['id']) + ) + affected += 1 + except (json.JSONDecodeError, TypeError): + continue + conn.commit() + + self.logger.info(f"Deleted tag '{tag_name}' from {affected} prompts") + return {'success': True, 'affected_count': affected} + + def merge_tags(self, source_tags: List[str], target_tag: str) -> Dict[str, Any]: + """ + Merge one or more source tags into a target tag across all prompts. + + Args: + source_tags: List of tag names to merge from + target_tag: Tag name to merge into + + Returns: + Dict with success status, affected_count, and tags_merged + """ + if not source_tags: + raise ValueError("Source tags list cannot be empty") + if not target_tag or not target_tag.strip(): + raise ValueError("Target tag cannot be empty") + + target_tag = target_tag.strip() + source_tags = [t.strip() for t in source_tags if t.strip()] + affected = 0 + tags_merged = 0 + + with self.model.get_connection() as conn: + for src_tag in source_tags: + cursor = conn.execute( + "SELECT id, tags FROM prompts WHERE tags LIKE ?", + (f'%"{src_tag}"%',) + ) + src_affected = 0 + for row in cursor.fetchall(): + try: + tags = json.loads(row['tags']) + if not isinstance(tags, list) or src_tag not in tags: + continue + tags.remove(src_tag) + if target_tag not in tags: + tags.append(target_tag) + conn.execute( + "UPDATE prompts SET tags = ?, updated_at = ? WHERE id = ?", + (json.dumps(tags), datetime.datetime.now(datetime.timezone.utc).isoformat(), row['id']) + ) + src_affected += 1 + except (json.JSONDecodeError, TypeError): + 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} + + def get_untagged_prompts_count(self) -> int: + """ + Get the count of prompts that have no tags. + + Returns: + Number of untagged prompts + """ + with self.model.get_connection() as conn: + cursor = conn.execute( + "SELECT COUNT(*) as total FROM prompts WHERE tags IS NULL OR tags = '' OR tags = '[]'" + ) + return cursor.fetchone()['total'] + + def get_untagged_prompts(self, limit: int = 20, offset: int = 0) -> Dict[str, Any]: + """ + Get prompts that have no tags, with pagination. + + Args: + limit: Maximum number of prompts to return + offset: Number of prompts to skip + + Returns: + Dict with prompts list, total count, and pagination + """ + with self.model.get_connection() as conn: + where = "tags IS NULL OR tags = '' OR tags = '[]'" + + cursor = conn.execute(f"SELECT COUNT(*) as total FROM prompts WHERE {where}") + total = cursor.fetchone()['total'] + + cursor = conn.execute( + f"SELECT * FROM prompts WHERE {where} ORDER BY created_at DESC LIMIT ? OFFSET ?", + (limit, offset) + ) + rows = cursor.fetchall() + + prompts = [] + for row in rows: + prompt = self._row_to_dict(row) + img_cursor = conn.execute( + """SELECT id, prompt_id, image_path, filename, generation_time, width, height, format + FROM generated_images WHERE prompt_id = ? + ORDER BY generation_time DESC LIMIT 3""", + (prompt['id'],) + ) + images = [dict(img_row) for img_row in img_cursor.fetchall()] + count_cursor = conn.execute( + "SELECT COUNT(*) as cnt FROM generated_images WHERE prompt_id = ?", + (prompt['id'],) + ) + image_count = count_cursor.fetchone()['cnt'] + prompt['images'] = images + prompt['image_count'] = image_count + prompts.append(prompt) + + return { + 'prompts': prompts, + 'total': total, + 'limit': limit, + 'offset': offset, + 'has_more': (offset + limit) < total + } + def _row_to_dict(self, row: sqlite3.Row) -> Dict[str, Any]: """ Convert a database row to a dictionary with parsed JSON fields. diff --git a/py/api.py b/py/api.py index 0d754c8..5fdb69d 100644 --- a/py/api.py +++ b/py/api.py @@ -145,6 +145,32 @@ class PromptManagerAPI: async def get_categories_route(request): return await self.get_categories(request) + # Tag management endpoints (must be registered BEFORE /prompt_manager/tags) + @routes.get("/prompt_manager/tags/stats") + async def get_tags_stats_route(request): + return await self.get_tags_stats(request) + + @routes.get("/prompt_manager/tags/filter") + async def get_tags_filter_route(request): + return await self.get_tags_filter(request) + + # Bulk tag operations (register BEFORE {tag_name} to avoid path param match) + @routes.post("/prompt_manager/tags/merge") + async def merge_tags_route(request): + return await self.merge_tags_endpoint(request) + + @routes.put("/prompt_manager/tags/{tag_name}") + async def rename_tag_route(request): + return await self.rename_tag_endpoint(request) + + @routes.delete("/prompt_manager/tags/{tag_name}") + async def delete_tag_route(request): + return await self.delete_tag_endpoint(request) + + @routes.get("/prompt_manager/tags/{tag_name}/prompts") + async def get_tag_prompts_route(request): + return await self.get_tag_prompts(request) + @routes.get("/prompt_manager/tags") async def get_tags_route(request): return await self.get_tags(request) @@ -342,6 +368,43 @@ class PromptManagerAPI: content_type=content_type, ) + # Serve static JS files from web/js directory + @routes.get("/prompt_manager/js/{filepath:.*}") + async def serve_js_static(request): + """Serve static JavaScript files from web/js directory.""" + import os + + MIME_TYPES = { + ".js": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".map": "application/json", + } + + filepath = request.match_info.get("filepath", "") + + if ".." in filepath or filepath.startswith("/"): + return web.Response(text="Forbidden", status=403) + + current_dir = os.path.dirname( + os.path.dirname(os.path.abspath(__file__)) + ) + file_path = os.path.join(current_dir, "web", "js", filepath) + + if not os.path.exists(file_path) or not os.path.isfile(file_path): + return web.Response(text=f"Not Found: {filepath}", status=404) + + ext = os.path.splitext(file_path)[1].lower() + content_type = MIME_TYPES.get(ext, "application/octet-stream") + + with open(file_path, "rb") as f: + content = f.read() + + return web.Response( + body=content, + content_type=content_type, + ) + # Statistics endpoint @routes.get("/prompt_manager/stats") async def get_stats_route(request): @@ -754,6 +817,249 @@ class PromptManagerAPI: status=500, ) + 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)) + search = request.query.get("search", "").strip() or None + sort = request.query.get("sort", "alpha_asc") + + result = self.db.get_tags_with_counts(limit, offset, search, sort) + untagged_count = self.db.get_untagged_prompts_count() + + return web.json_response({ + "success": True, + "tags": result['tags'], + "untagged_count": untagged_count, + "pagination": { + "total": result['total'], + "limit": result['limit'], + "offset": result['offset'], + "has_more": result['has_more'] + } + }) + except Exception as e: + self.logger.error(f"Tags stats error: {e}", exc_info=True) + return web.json_response( + {"success": False, "error": str(e)}, + status=500, + ) + + def _enrich_prompt_images(self, prompts): + """Add url and thumbnail_url to each image in prompt results.""" + from pathlib import Path + from urllib.parse import quote as url_quote + + output_dir = self._find_comfyui_output_dir() + output_path = Path(output_dir) if output_dir else None + + for prompt in prompts: + for image in prompt.get('images', []): + image_path_str = image.get('image_path', '') + if not image_path_str: + continue + + img_path = Path(image_path_str) + + # Set fallback url via image ID + if image.get('id'): + image['url'] = f"/prompt_manager/images/{image['id']}/file" + + # Try to compute relative path and thumbnail URL + if output_path: + try: + rel_path = img_path.resolve().relative_to(output_path.resolve()) + image['relative_path'] = str(rel_path) + image['url'] = f"/prompt_manager/images/serve/{url_quote(rel_path.as_posix(), safe='/')}" + + # Check for thumbnail + rel_no_ext = rel_path.with_suffix('') + thumb_rel = f"thumbnails/{rel_no_ext.as_posix()}_thumb{rel_path.suffix}" + thumb_abs = output_path / thumb_rel + if thumb_abs.exists(): + image['thumbnail_url'] = f"/prompt_manager/images/serve/{url_quote(thumb_rel, safe='/')}" + except (ValueError, RuntimeError): + pass + + return prompts + + async def get_tag_prompts(self, request): + """Get prompts for a single tag.""" + try: + from urllib.parse import unquote + tag_name = unquote(request.match_info.get("tag_name", "")) + if not tag_name: + return web.json_response( + {"success": False, "error": "Tag name required"}, + status=400, + ) + + limit = int(request.query.get("limit", 20)) + offset = int(request.query.get("offset", 0)) + + result = self.db.get_prompts_by_tags([tag_name], 'and', limit, offset) + self._enrich_prompt_images(result['prompts']) + + return web.json_response({ + "success": True, + "tag": tag_name, + "prompts": result['prompts'], + "pagination": { + "total": result['total'], + "limit": result['limit'], + "offset": result['offset'], + "has_more": result['has_more'] + } + }) + except Exception as e: + self.logger.error(f"Tag prompts error: {e}", exc_info=True) + return web.json_response( + {"success": False, "error": str(e)}, + status=500, + ) + + async def get_tags_filter(self, request): + """Get prompts matching multiple tags with AND/OR mode, or untagged prompts.""" + try: + untagged = request.query.get("untagged", "").lower() == "true" + + if untagged: + limit = int(request.query.get("limit", 20)) + offset = int(request.query.get("offset", 0)) + result = self.db.get_untagged_prompts(limit, offset) + self._enrich_prompt_images(result['prompts']) + return web.json_response({ + "success": True, + "tags": [], + "mode": "untagged", + "prompts": result['prompts'], + "pagination": { + "total": result['total'], + "limit": result['limit'], + "offset": result['offset'], + "has_more": result['has_more'] + } + }) + + tags_str = request.query.get("tags", "").strip() + if not tags_str: + return web.json_response( + {"success": False, "error": "Tags parameter required"}, + status=400, + ) + + tags_list = [t.strip() for t in tags_str.split(",") if t.strip()] + mode = request.query.get("mode", "and").lower() + if mode not in ("and", "or"): + mode = "and" + + limit = int(request.query.get("limit", 20)) + offset = int(request.query.get("offset", 0)) + + result = self.db.get_prompts_by_tags(tags_list, mode, limit, offset) + self._enrich_prompt_images(result['prompts']) + + return web.json_response({ + "success": True, + "tags": tags_list, + "mode": mode, + "prompts": result['prompts'], + "pagination": { + "total": result['total'], + "limit": result['limit'], + "offset": result['offset'], + "has_more": result['has_more'] + } + }) + except Exception as e: + self.logger.error(f"Tags filter error: {e}", exc_info=True) + return web.json_response( + {"success": False, "error": str(e)}, + status=500, + ) + + async def rename_tag_endpoint(self, request): + """Rename a tag across all prompts.""" + try: + from urllib.parse import unquote + tag_name = unquote(request.match_info.get("tag_name", "")) + if not tag_name: + return web.json_response( + {"success": False, "error": "Tag name required"}, status=400 + ) + + body = await request.json() + new_name = body.get("new_name", "").strip() + if not new_name: + return web.json_response( + {"success": False, "error": "New tag name required"}, status=400 + ) + + result = self.db.rename_tag_all_prompts(tag_name, new_name) + return web.json_response({ + "success": True, + "old_name": tag_name, + "new_name": new_name, + "affected_count": result['affected_count'] + }) + except Exception as e: + self.logger.error(f"Rename tag error: {e}", exc_info=True) + return web.json_response( + {"success": False, "error": str(e)}, status=500 + ) + + async def delete_tag_endpoint(self, request): + """Delete a tag from all prompts.""" + try: + from urllib.parse import unquote + tag_name = unquote(request.match_info.get("tag_name", "")) + if not tag_name: + return web.json_response( + {"success": False, "error": "Tag name required"}, status=400 + ) + + result = self.db.delete_tag_all_prompts(tag_name) + return web.json_response({ + "success": True, + "tag_name": tag_name, + "affected_count": result['affected_count'] + }) + except Exception as e: + self.logger.error(f"Delete tag error: {e}", exc_info=True) + return web.json_response( + {"success": False, "error": str(e)}, status=500 + ) + + async def merge_tags_endpoint(self, request): + """Merge source tags into a target tag.""" + try: + body = await request.json() + source_tags = body.get("source_tags", []) + target_tag = body.get("target_tag", "").strip() + + if not source_tags: + return web.json_response( + {"success": False, "error": "Source tags required"}, status=400 + ) + if not target_tag: + return web.json_response( + {"success": False, "error": "Target tag required"}, status=400 + ) + + result = self.db.merge_tags(source_tags, target_tag) + return web.json_response({ + "success": True, + "target_tag": target_tag, + "affected_count": result['affected_count'], + "tags_merged": result['tags_merged'] + }) + except Exception as e: + self.logger.error(f"Merge tags error: {e}", exc_info=True) + return web.json_response( + {"success": False, "error": str(e)}, status=500 + ) + async def save_prompt(self, request): """Save a new prompt with metadata and duplicate detection. diff --git a/web/admin.html b/web/admin.html index fd6b87a..0e42694 100644 --- a/web/admin.html +++ b/web/admin.html @@ -181,6 +181,9 @@ + +
+
@@ -207,7 +210,8 @@
-
+

Unique Tags

@@ -417,6 +421,84 @@
+
+ + + + @@ -1375,14 +1457,52 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr totalPages: 1 }; + this.tagsPage = null; + this.init(); } init() { this.bindEvents(); + this.initRouter(); this.loadInitialData(); } + initRouter() { + window.addEventListener('hashchange', () => this.handleRoute()); + this.handleRoute(); + } + + handleRoute() { + const hash = window.location.hash; + if (hash === '#/tags' || hash.startsWith('#/tags/')) { + this.showTagsPage(); + } else { + this.showDashboard(); + } + } + + showDashboard() { + const dashboard = document.getElementById('dashboardView'); + const tagsPage = document.getElementById('tagsPageView'); + if (dashboard) dashboard.classList.remove('hidden'); + if (tagsPage) tagsPage.classList.add('hidden'); + } + + showTagsPage() { + const dashboard = document.getElementById('dashboardView'); + const tagsPage = document.getElementById('tagsPageView'); + if (dashboard) dashboard.classList.add('hidden'); + if (tagsPage) tagsPage.classList.remove('hidden'); + + // Lazy-initialize TagsPageManager + if (!this.tagsPage && typeof TagsPageManager !== 'undefined') { + this.tagsPage = new TagsPageManager(this); + } else if (this.tagsPage) { + this.tagsPage.loadTagsList(); + } + } + bindEvents() { // Search document.getElementById("searchBtn").addEventListener("click", () => this.search()); @@ -5497,5 +5617,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; } }); + \ No newline at end of file diff --git a/web/js/tags-page.js b/web/js/tags-page.js new file mode 100644 index 0000000..584317c --- /dev/null +++ b/web/js/tags-page.js @@ -0,0 +1,994 @@ +/** + * TagsPageManager - Tag management page with two-panel layout + * Left: searchable/sortable tag list with counts + infinite scroll + * Right: prompt cards with image thumbnails for selected tag(s) + */ +class TagsPageManager { + constructor(admin) { + this.admin = admin; + + // Tag list state + this.tags = []; + this.tagOffset = 0; + this.tagLimit = 50; + this.hasMoreTags = false; + this.tagSearch = ''; + this.tagSort = 'alpha_asc'; + this.isLoadingTags = false; + this.maxTagCount = 1; + this.untaggedCount = 0; + + // Selection state + this.selectedTags = []; + this.filterMode = 'and'; + + // Prompts state + this.prompts = []; + this.promptOffset = 0; + this.promptLimit = 20; + this.promptTotal = 0; + this.hasMorePrompts = false; + this.isLoadingPrompts = false; + + // Observers + this.tagObserver = null; + this.promptObserver = null; + + // Debounce timer + this.searchTimer = null; + + // Context menu state + this.contextMenuTag = null; + + this.bindEvents(); + this.initInfiniteScroll(); + this.buildContextMenu(); + this.parseUrlState(); + this.loadTagsList(); + } + + // ── URL State Sync ──────────────────────────────────────── + + parseUrlState() { + const hash = window.location.hash; + if (!hash.startsWith('#/tags/')) return; + + const rest = hash.substring('#/tags/'.length); + if (!rest) return; + + // Parse query params from hash (e.g., #/tags/landscape,portrait?mode=or) + const [tagsPart, queryPart] = rest.split('?'); + const tagNames = tagsPart.split(',').map(t => decodeURIComponent(t.trim())).filter(Boolean); + + if (tagNames.length > 0) { + this.selectedTags = tagNames; + } + + if (queryPart) { + const params = new URLSearchParams(queryPart); + const mode = params.get('mode'); + if (mode === 'or') this.filterMode = 'or'; + } + } + + updateUrlHash() { + if (this.selectedTags.length === 0) { + if (window.location.hash !== '#/tags') { + history.replaceState(null, '', '#/tags'); + } + return; + } + + const tagsStr = this.selectedTags.map(t => encodeURIComponent(t)).join(','); + let hash = `#/tags/${tagsStr}`; + if (this.filterMode === 'or' && this.selectedTags.length > 1) { + hash += '?mode=or'; + } + if (window.location.hash !== hash) { + history.replaceState(null, '', hash); + } + } + + // ── Tag List Methods ────────────────────────────────────── + + async loadTagsList() { + if (this.isLoadingTags) return; + this.isLoadingTags = true; + this.tagOffset = 0; + this.tags = []; + + try { + const params = new URLSearchParams({ + limit: this.tagLimit, + offset: 0, + sort: this.tagSort + }); + if (this.tagSearch) params.set('search', this.tagSearch); + + const resp = await fetch(`/prompt_manager/tags/stats?${params}`); + const data = await resp.json(); + + if (data.success) { + this.tags = data.tags; + this.hasMoreTags = data.pagination.has_more; + this.tagOffset = this.tagLimit; + this.untaggedCount = data.untagged_count || 0; + this.maxTagCount = Math.max(1, ...this.tags.map(t => t.count)); + this.renderTagsList(); + + const badge = document.getElementById('tagsTotalBadge'); + if (badge) badge.textContent = `${data.pagination.total} tags`; + + // If we have pre-selected tags from URL, load prompts + if (this.selectedTags.length > 0) { + this.updateActiveTagPills(); + this.loadTagPrompts(); + } + } + } catch (err) { + console.error('Failed to load tags:', err); + } finally { + this.isLoadingTags = false; + } + } + + async loadMoreTags() { + if (this.isLoadingTags || !this.hasMoreTags) return; + this.isLoadingTags = true; + + const loader = document.getElementById('tagsLoader'); + if (loader) loader.classList.remove('hidden'); + + try { + const params = new URLSearchParams({ + limit: this.tagLimit, + offset: this.tagOffset, + sort: this.tagSort + }); + if (this.tagSearch) params.set('search', this.tagSearch); + + const resp = await fetch(`/prompt_manager/tags/stats?${params}`); + const data = await resp.json(); + + if (data.success) { + this.tags = this.tags.concat(data.tags); + this.hasMoreTags = data.pagination.has_more; + this.tagOffset += this.tagLimit; + this.maxTagCount = Math.max(1, ...this.tags.map(t => t.count)); + this.renderTagsList(); + } + } catch (err) { + console.error('Failed to load more tags:', err); + } finally { + this.isLoadingTags = false; + if (loader) loader.classList.add('hidden'); + } + } + + renderTagsList() { + const container = document.getElementById('tagsList'); + if (!container) return; + + const fragment = document.createDocumentFragment(); + + // Untagged entry at top + if (this.untaggedCount > 0) { + fragment.appendChild(this.createUntaggedElement()); + } + + this.tags.forEach(tag => { + fragment.appendChild(this.createTagElement(tag)); + }); + container.replaceChildren(fragment); + } + + createUntaggedElement() { + const isSelected = this.selectedTags.includes('__untagged__'); + const div = document.createElement('div'); + div.className = `flex items-center px-3 py-2 rounded-lg cursor-pointer border transition-all duration-200 relative overflow-hidden ${ + isSelected ? 'bg-yellow-600/20 border-yellow-500/50' : 'border-transparent hover:bg-gray-700/50' + }`; + + div.addEventListener('click', () => this.selectTag('__untagged__')); + + const icon = document.createElement('span'); + icon.className = 'mr-3 text-sm'; + icon.textContent = '\u2205'; + + const nameSpan = document.createElement('span'); + nameSpan.className = 'flex-1 text-sm text-yellow-300 italic truncate'; + nameSpan.textContent = '[untagged]'; + + const countSpan = document.createElement('span'); + countSpan.className = 'text-xs text-gray-500 ml-2 font-mono'; + countSpan.textContent = this.untaggedCount; + + div.appendChild(icon); + div.appendChild(nameSpan); + div.appendChild(countSpan); + + return div; + } + + createTagElement(tag) { + const isSelected = this.selectedTags.includes(tag.name); + const div = document.createElement('div'); + div.className = `flex items-center px-3 py-2 rounded-lg cursor-pointer border transition-all duration-200 relative overflow-hidden group ${ + isSelected ? 'bg-green-600/20 border-green-500/50' : 'border-transparent hover:bg-gray-700/50' + }`; + div.dataset.tagName = tag.name; + + // Usage bar (background) + const barWidth = (tag.count / this.maxTagCount) * 100; + const bar = document.createElement('div'); + bar.className = 'absolute inset-y-0 left-0 bg-green-500/8 pointer-events-none transition-all duration-300'; + bar.style.width = barWidth + '%'; + div.appendChild(bar); + + const tagName = tag.name; + div.addEventListener('click', () => this.selectTag(tagName)); + div.addEventListener('contextmenu', (e) => { + e.preventDefault(); + this.showContextMenu(e, tagName); + }); + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'tag-filter-checkbox mr-3 rounded border-gray-500 bg-gray-700 text-green-500 focus:ring-green-500 cursor-pointer relative z-10'; + checkbox.checked = isSelected; + checkbox.dataset.tag = tag.name; + checkbox.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleTagFilter(tagName); + }); + + const nameSpan = document.createElement('span'); + nameSpan.className = 'flex-1 text-sm text-gray-200 truncate relative z-10'; + nameSpan.textContent = tag.name; + + const countSpan = document.createElement('span'); + countSpan.className = 'text-xs text-gray-500 ml-2 font-mono relative z-10'; + countSpan.textContent = tag.count; + + div.appendChild(checkbox); + div.appendChild(nameSpan); + div.appendChild(countSpan); + + return div; + } + + // ── Context Menu ────────────────────────────────────────── + + buildContextMenu() { + const menu = document.getElementById('tagContextMenu'); + if (!menu) return; + + const items = [ + { label: 'Rename Tag', icon: '\u270F\uFE0F', action: () => this.renameTag(this.contextMenuTag) }, + { label: 'Delete Tag', icon: '\uD83D\uDDD1\uFE0F', action: () => this.deleteTag(this.contextMenuTag) }, + { label: 'Merge Into\u2026', icon: '\uD83D\uDD00', action: () => this.mergeTag(this.contextMenuTag) } + ]; + + const fragment = document.createDocumentFragment(); + items.forEach(item => { + const btn = document.createElement('button'); + btn.className = 'w-full text-left px-4 py-2 text-sm text-gray-200 hover:bg-gray-700 flex items-center space-x-2'; + const iconSpan = document.createElement('span'); + iconSpan.textContent = item.icon; + const labelSpan = document.createElement('span'); + labelSpan.textContent = item.label; + btn.appendChild(iconSpan); + btn.appendChild(labelSpan); + btn.addEventListener('click', () => { + this.hideContextMenu(); + item.action(); + }); + fragment.appendChild(btn); + }); + menu.replaceChildren(fragment); + + // Close on click outside + document.addEventListener('click', (e) => { + if (!menu.contains(e.target)) this.hideContextMenu(); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') this.hideContextMenu(); + }); + } + + showContextMenu(e, tagName) { + const menu = document.getElementById('tagContextMenu'); + if (!menu) return; + + this.contextMenuTag = tagName; + + // Position at mouse, clamped to viewport + let x = e.clientX; + let y = e.clientY; + menu.classList.remove('hidden'); + + const rect = menu.getBoundingClientRect(); + if (x + rect.width > window.innerWidth) x = window.innerWidth - rect.width - 8; + if (y + rect.height > window.innerHeight) y = window.innerHeight - rect.height - 8; + + menu.style.left = x + 'px'; + menu.style.top = y + 'px'; + } + + hideContextMenu() { + const menu = document.getElementById('tagContextMenu'); + if (menu) menu.classList.add('hidden'); + this.contextMenuTag = null; + } + + // ── Tag Operations ──────────────────────────────────────── + + async renameTag(tagName) { + if (!tagName) return; + const newName = prompt(`Rename tag "${tagName}" to:`, tagName); + if (!newName || newName.trim() === '' || newName.trim() === tagName) return; + + try { + const resp = await fetch(`/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'); + // Update selectedTags if the renamed tag was selected + const idx = this.selectedTags.indexOf(tagName); + if (idx >= 0) this.selectedTags[idx] = newName.trim(); + this.updateUrlHash(); + this.loadTagsList(); + if (this.selectedTags.length > 0) this.loadTagPrompts(); + } else { + this.admin.showNotification(data.error || 'Rename failed', 'error'); + } + } catch (err) { + console.error('Rename tag error:', err); + this.admin.showNotification('Failed to rename tag', 'error'); + } + } + + async deleteTag(tagName) { + if (!tagName) return; + if (!confirm(`Delete tag "${tagName}" from all prompts? This cannot be undone.`)) return; + + try { + const resp = await fetch(`/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'); + this.selectedTags = this.selectedTags.filter(t => t !== tagName); + this.updateUrlHash(); + this.loadTagsList(); + if (this.selectedTags.length > 0) { + this.loadTagPrompts(); + } else { + this.clearPrompts(); + this.updateActiveTagPills(); + } + } else { + this.admin.showNotification(data.error || 'Delete failed', 'error'); + } + } catch (err) { + console.error('Delete tag error:', err); + this.admin.showNotification('Failed to delete tag', 'error'); + } + } + + async mergeTag(tagName) { + if (!tagName) return; + const targetTag = prompt(`Merge "${tagName}" into which tag?`); + if (!targetTag || targetTag.trim() === '' || targetTag.trim() === tagName) return; + + try { + const resp = await fetch('/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' + ); + this.selectedTags = this.selectedTags.filter(t => t !== tagName); + this.updateUrlHash(); + this.loadTagsList(); + if (this.selectedTags.length > 0) { + this.loadTagPrompts(); + } else { + this.clearPrompts(); + this.updateActiveTagPills(); + } + } else { + this.admin.showNotification(data.error || 'Merge failed', 'error'); + } + } catch (err) { + console.error('Merge tag error:', err); + this.admin.showNotification('Failed to merge tag', 'error'); + } + } + + // ── Tag Selection ───────────────────────────────────────── + + selectTag(name) { + this.selectedTags = [name]; + this.renderTagsList(); + this.updateActiveTagPills(); + this.updateUrlHash(); + this.loadTagPrompts(); + } + + toggleTagFilter(name) { + const idx = this.selectedTags.indexOf(name); + if (idx >= 0) { + this.selectedTags.splice(idx, 1); + } else { + // Don't allow mixing untagged with regular tags + if (name === '__untagged__') { + this.selectedTags = ['__untagged__']; + } else { + this.selectedTags = this.selectedTags.filter(t => t !== '__untagged__'); + this.selectedTags.push(name); + } + } + + this.renderTagsList(); + this.updateActiveTagPills(); + this.updateUrlHash(); + + if (this.selectedTags.length > 0) { + this.loadTagPrompts(); + } else { + this.clearPrompts(); + } + } + + setFilterMode(mode) { + this.filterMode = mode; + const andBtn = document.getElementById('filterModeAnd'); + const orBtn = document.getElementById('filterModeOr'); + + if (mode === 'and') { + andBtn.className = 'px-3 py-1 text-xs font-medium rounded bg-green-600 text-white'; + orBtn.className = 'px-3 py-1 text-xs font-medium rounded bg-gray-700 text-gray-300 hover:bg-gray-600'; + } else { + orBtn.className = 'px-3 py-1 text-xs font-medium rounded bg-green-600 text-white'; + andBtn.className = 'px-3 py-1 text-xs font-medium rounded bg-gray-700 text-gray-300 hover:bg-gray-600'; + } + + this.updateUrlHash(); + + if (this.selectedTags.length > 0) { + this.loadTagPrompts(); + } + } + + updateActiveTagPills() { + const pillsArea = document.getElementById('activeTagsPills'); + const pillsContainer = document.getElementById('tagPillsContainer'); + const filterToggle = document.getElementById('filterModeToggle'); + const emptyState = document.getElementById('tagPromptsEmpty'); + const countBadge = document.getElementById('tagPromptsCountBadge'); + + if (this.selectedTags.length === 0) { + if (pillsArea) pillsArea.classList.add('hidden'); + if (filterToggle) filterToggle.classList.add('hidden'); + if (filterToggle) filterToggle.classList.remove('flex'); + if (emptyState) emptyState.classList.remove('hidden'); + if (countBadge) countBadge.textContent = ''; + return; + } + + if (emptyState) emptyState.classList.add('hidden'); + if (pillsArea) pillsArea.classList.remove('hidden'); + + // Show AND/OR toggle only for multi-tag selection (not untagged) + const isUntagged = this.selectedTags.includes('__untagged__'); + if (this.selectedTags.length > 1 && !isUntagged) { + if (filterToggle) { + filterToggle.classList.remove('hidden'); + filterToggle.classList.add('flex'); + } + } else { + if (filterToggle) { + filterToggle.classList.add('hidden'); + filterToggle.classList.remove('flex'); + } + } + + // Build pills using safe DOM methods + if (pillsContainer) { + const fragment = document.createDocumentFragment(); + this.selectedTags.forEach(tag => { + const pill = document.createElement('span'); + const isUntaggedPill = tag === '__untagged__'; + pill.className = `inline-flex items-center px-3 py-1 rounded-full text-sm ${ + isUntaggedPill + ? 'bg-yellow-600/20 text-yellow-300 border border-yellow-500/30' + : 'bg-green-600/20 text-green-300 border border-green-500/30' + }`; + + const text = document.createTextNode(isUntaggedPill ? '[untagged]' : tag); + pill.appendChild(text); + + const btn = document.createElement('button'); + btn.className = isUntaggedPill ? 'ml-2 text-yellow-400 hover:text-white' : 'ml-2 text-green-400 hover:text-white'; + btn.textContent = '\u00d7'; + btn.addEventListener('click', () => this.removeTagFromFilter(tag)); + pill.appendChild(btn); + + fragment.appendChild(pill); + }); + pillsContainer.replaceChildren(fragment); + } + } + + removeTagFromFilter(name) { + this.selectedTags = this.selectedTags.filter(t => t !== name); + this.renderTagsList(); + this.updateActiveTagPills(); + this.updateUrlHash(); + + if (this.selectedTags.length > 0) { + this.loadTagPrompts(); + } else { + this.clearPrompts(); + } + } + + // ── Prompts Display ─────────────────────────────────────── + + async loadTagPrompts() { + if (this.isLoadingPrompts) return; + if (this.selectedTags.length === 0) return; + + this.isLoadingPrompts = true; + this.promptOffset = 0; + this.prompts = []; + + const grid = document.getElementById('tagPromptsGrid'); + if (grid) { + grid.replaceChildren(); + const spinner = document.createElement('div'); + spinner.className = 'col-span-full text-center py-8'; + const spinEl = document.createElement('div'); + spinEl.className = 'w-8 h-8 border-2 border-green-500/30 border-t-green-500 rounded-full animate-spin mx-auto'; + spinner.appendChild(spinEl); + grid.appendChild(spinner); + } + + try { + let url; + const params = new URLSearchParams({ + limit: this.promptLimit, + offset: 0 + }); + + const isUntagged = this.selectedTags.includes('__untagged__'); + + if (isUntagged) { + params.set('untagged', 'true'); + url = `/prompt_manager/tags/filter?${params}`; + } else if (this.selectedTags.length === 1) { + url = `/prompt_manager/tags/${encodeURIComponent(this.selectedTags[0])}/prompts?${params}`; + } else { + params.set('tags', this.selectedTags.join(',')); + params.set('mode', this.filterMode); + url = `/prompt_manager/tags/filter?${params}`; + } + + const resp = await fetch(url); + const data = await resp.json(); + + if (data.success) { + this.prompts = data.prompts; + this.hasMorePrompts = data.pagination.has_more; + this.promptTotal = data.pagination.total; + this.promptOffset = this.promptLimit; + this.renderTagPrompts(); + this.updatePromptCountBadge(); + } + } catch (err) { + console.error('Failed to load tag prompts:', err); + if (grid) { + grid.replaceChildren(); + const errDiv = document.createElement('div'); + errDiv.className = 'col-span-full text-center py-8 text-red-400'; + errDiv.textContent = 'Failed to load prompts'; + grid.appendChild(errDiv); + } + } finally { + this.isLoadingPrompts = false; + } + } + + async loadMoreTagPrompts() { + if (this.isLoadingPrompts || !this.hasMorePrompts) return; + this.isLoadingPrompts = true; + + const loader = document.getElementById('tagPromptsLoader'); + if (loader) loader.classList.remove('hidden'); + + try { + const params = new URLSearchParams({ + limit: this.promptLimit, + offset: this.promptOffset + }); + + let url; + const isUntagged = this.selectedTags.includes('__untagged__'); + + if (isUntagged) { + params.set('untagged', 'true'); + url = `/prompt_manager/tags/filter?${params}`; + } else if (this.selectedTags.length === 1) { + url = `/prompt_manager/tags/${encodeURIComponent(this.selectedTags[0])}/prompts?${params}`; + } else { + params.set('tags', this.selectedTags.join(',')); + params.set('mode', this.filterMode); + url = `/prompt_manager/tags/filter?${params}`; + } + + const resp = await fetch(url); + const data = await resp.json(); + + if (data.success) { + this.prompts = this.prompts.concat(data.prompts); + this.hasMorePrompts = data.pagination.has_more; + this.promptOffset += this.promptLimit; + this.renderTagPrompts(); + this.updatePromptCountBadge(); + } + } catch (err) { + console.error('Failed to load more prompts:', err); + } finally { + this.isLoadingPrompts = false; + if (loader) loader.classList.add('hidden'); + } + } + + updatePromptCountBadge() { + const badge = document.getElementById('tagPromptsCountBadge'); + if (badge) { + badge.textContent = this.promptTotal > 0 + ? `${this.prompts.length} of ${this.promptTotal} prompts` + : ''; + } + } + + renderTagPrompts() { + const grid = document.getElementById('tagPromptsGrid'); + if (!grid) return; + + if (this.prompts.length === 0) { + grid.replaceChildren(); + const emptyDiv = document.createElement('div'); + emptyDiv.className = 'col-span-full text-center py-12 text-gray-500'; + const icon = document.createElement('span'); + icon.className = 'text-3xl block mb-2'; + icon.textContent = '\uD83D\uDCED'; + emptyDiv.appendChild(icon); + const msg = document.createElement('span'); + msg.textContent = 'No prompts found with the selected tag(s)'; + emptyDiv.appendChild(msg); + grid.appendChild(emptyDiv); + return; + } + + const fragment = document.createDocumentFragment(); + this.prompts.forEach((p, index) => { + const card = this.createPromptCard(p); + // Staggered fade-in + card.style.opacity = '0'; + card.style.transform = 'translateY(8px)'; + card.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + card.style.transitionDelay = (index % 9) * 30 + 'ms'; + fragment.appendChild(card); + }); + grid.replaceChildren(fragment); + + // Trigger animations + requestAnimationFrame(() => { + grid.querySelectorAll('.tag-prompt-card').forEach(card => { + card.style.opacity = '1'; + card.style.transform = 'translateY(0)'; + }); + }); + } + + createPromptCard(prompt) { + const card = document.createElement('div'); + card.className = 'tag-prompt-card bg-gray-800/50 rounded-xl p-4 border border-gray-700/50 hover:border-gray-600 transition-colors cursor-pointer'; + + // Click card → navigate to dashboard with search + card.addEventListener('click', () => { + const fragment = (prompt.text || '').substring(0, 60); + window.location.hash = ''; + setTimeout(() => { + const searchInput = document.getElementById('searchText'); + if (searchInput) { + searchInput.value = fragment; + if (this.admin && typeof this.admin.search === 'function') { + this.admin.search(); + } + } + }, 100); + }); + + // Thumbnail row + if (prompt.images && prompt.images.length > 0) { + const thumbRow = document.createElement('div'); + thumbRow.className = 'flex gap-2 mb-3'; + + prompt.images.forEach((img, imgIndex) => { + const imgEl = document.createElement('img'); + imgEl.src = this.getThumbnailUrl(img); + imgEl.alt = ''; + imgEl.loading = 'lazy'; + imgEl.className = 'w-20 h-20 object-cover rounded-lg bg-gray-700 cursor-zoom-in hover:ring-2 hover:ring-green-500/50 transition-all'; + imgEl.onerror = function() { + this.onerror = null; + this.src = 'data:image/svg+xml,' + encodeURIComponent('?'); + }; + // Click thumbnail → ViewerJS + imgEl.addEventListener('click', (e) => { + e.stopPropagation(); + this.openImageViewer(prompt, imgIndex); + }); + thumbRow.appendChild(imgEl); + }); + + const moreCount = (prompt.image_count || 0) - prompt.images.length; + if (moreCount > 0) { + const moreSpan = document.createElement('span'); + moreSpan.className = 'w-20 h-20 rounded-lg bg-gray-700 flex items-center justify-center text-gray-400 text-sm font-medium cursor-zoom-in hover:bg-gray-600 transition-colors'; + moreSpan.textContent = `+${moreCount}`; + moreSpan.addEventListener('click', (e) => { + e.stopPropagation(); + this.openImageViewer(prompt, prompt.images.length); + }); + thumbRow.appendChild(moreSpan); + } + + card.appendChild(thumbRow); + } + + // Prompt text (truncated) + const textP = document.createElement('p'); + textP.className = 'text-sm text-gray-200 leading-relaxed line-clamp-3 mb-3'; + textP.textContent = prompt.text || ''; + card.appendChild(textP); + + // Meta row + const metaRow = document.createElement('div'); + metaRow.className = 'flex items-center justify-between text-xs text-gray-500 mb-2'; + + const metaLeft = document.createElement('div'); + metaLeft.className = 'flex items-center space-x-3'; + + if (prompt.category) { + const catSpan = document.createElement('span'); + catSpan.className = 'px-2 py-0.5 bg-purple-600/20 text-purple-300 rounded'; + catSpan.textContent = prompt.category; + metaLeft.appendChild(catSpan); + } + + if (prompt.created_at) { + const dateSpan = document.createElement('span'); + dateSpan.textContent = new Date(prompt.created_at).toLocaleDateString(); + metaLeft.appendChild(dateSpan); + } + + // Rating stars + const rating = prompt.rating || 0; + const starsSpan = document.createElement('span'); + starsSpan.className = 'flex'; + for (let i = 1; i <= 5; i++) { + const star = document.createElement('span'); + star.className = i <= rating ? 'text-yellow-400' : 'text-gray-600'; + star.textContent = '\u2605'; + starsSpan.appendChild(star); + } + metaLeft.appendChild(starsSpan); + + metaRow.appendChild(metaLeft); + + if (prompt.image_count) { + const imgCount = document.createElement('span'); + imgCount.className = 'text-gray-500'; + imgCount.textContent = `${prompt.image_count} img${prompt.image_count !== 1 ? 's' : ''}`; + metaRow.appendChild(imgCount); + } + + card.appendChild(metaRow); + + // Tags + 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 => { + 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; + tagsDiv.appendChild(tagSpan); + }); + card.appendChild(tagsDiv); + } + + return card; + } + + // ── Image Viewer ────────────────────────────────────────── + + async openImageViewer(prompt, startIndex) { + // Load all images for this prompt + let images = prompt.images || []; + + // 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(); + if (data.success && data.images) { + images = data.images; + } + } catch (err) { + console.error('Failed to load all images:', err); + } + } + + if (images.length === 0) { + this.admin.showNotification('No images found for this prompt', 'warning'); + return; + } + + // Create temp container for ViewerJS + const container = document.createElement('div'); + container.style.display = 'none'; + + images.forEach((image, index) => { + const imageUrl = this.admin.getImageUrl(image); + if (!imageUrl) return; + + const img = document.createElement('img'); + img.src = imageUrl; + img.alt = image.filename || `Image ${index + 1}`; + container.appendChild(img); + }); + + document.body.appendChild(container); + + const viewer = new Viewer(container, { + inline: false, + navbar: true, + toolbar: { + zoomIn: 1, + zoomOut: 1, + oneToOne: 1, + reset: 1, + prev: 1, + play: false, + next: 1, + rotateLeft: 1, + rotateRight: 1, + flipHorizontal: 1, + flipVertical: 1 + }, + title: [1, (image) => image.alt || 'Image'], + hidden: () => { + viewer.destroy(); + container.remove(); + }, + initialViewIndex: Math.min(startIndex, images.length - 1) + }); + + viewer.show(); + } + + clearPrompts() { + this.prompts = []; + this.promptTotal = 0; + const grid = document.getElementById('tagPromptsGrid'); + if (grid) grid.replaceChildren(); + const emptyState = document.getElementById('tagPromptsEmpty'); + if (emptyState) emptyState.classList.remove('hidden'); + this.updatePromptCountBadge(); + } + + // ── Infinite Scroll ─────────────────────────────────────── + + initInfiniteScroll() { + // Tag list observer + const tagsLoader = document.getElementById('tagsLoader'); + if (tagsLoader) { + this.tagObserver = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && this.hasMoreTags && !this.isLoadingTags) { + this.loadMoreTags(); + } + }, { root: document.getElementById('tagsListContainer'), threshold: 0.1 }); + this.tagObserver.observe(tagsLoader); + } + + // Prompts grid observer + const promptsLoader = document.getElementById('tagPromptsLoader'); + if (promptsLoader) { + this.promptObserver = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && this.hasMorePrompts && !this.isLoadingPrompts) { + this.loadMoreTagPrompts(); + } + }, { root: document.getElementById('tagPromptsContainer'), threshold: 0.1 }); + this.promptObserver.observe(promptsLoader); + } + } + + destroyInfiniteScroll() { + if (this.tagObserver) { + this.tagObserver.disconnect(); + this.tagObserver = null; + } + if (this.promptObserver) { + this.promptObserver.disconnect(); + this.promptObserver = null; + } + } + + // ── Events ──────────────────────────────────────────────── + + bindEvents() { + // Tag search (debounced) + const searchInput = document.getElementById('tagSearchInput'); + if (searchInput) { + searchInput.addEventListener('input', () => { + clearTimeout(this.searchTimer); + this.searchTimer = setTimeout(() => { + this.tagSearch = searchInput.value.trim(); + this.loadTagsList(); + }, 300); + }); + } + + // Tag sort + const sortSelect = document.getElementById('tagSortSelect'); + if (sortSelect) { + sortSelect.addEventListener('change', () => { + this.tagSort = sortSelect.value; + this.loadTagsList(); + }); + } + + // Filter mode buttons + const andBtn = document.getElementById('filterModeAnd'); + const orBtn = document.getElementById('filterModeOr'); + if (andBtn) andBtn.addEventListener('click', () => this.setFilterMode('and')); + if (orBtn) orBtn.addEventListener('click', () => this.setFilterMode('or')); + } + + destroy() { + this.destroyInfiniteScroll(); + this.hideContextMenu(); + clearTimeout(this.searchTimer); + } + + // ── Helpers ─────────────────────────────────────────────── + + getThumbnailUrl(image) { + if (image.thumbnail_url) return image.thumbnail_url; + if (image.url) return image.url; + if (image.id) return `/prompt_manager/images/${image.id}/file`; + return ''; + } +} + +// Attach to window for global access +if (typeof window !== 'undefined') { + window.TagsPageManager = TagsPageManager; +}