feat(tags): add Phase 2 tag management enhancements

- 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
This commit is contained in:
Vito Sansevero
2026-02-03 18:40:58 -08:00
parent 778f4670be
commit 760b2a0f48
4 changed files with 1764 additions and 2 deletions
+342 -1
View File
@@ -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.
+306
View File
@@ -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.
+122 -1
View File
@@ -181,6 +181,9 @@
</div>
</header>
<!-- Dashboard View -->
<div id="dashboardView">
<!-- Stats Grid -->
<div class="px-6 py-6">
<div class="max-w-7xl mx-auto">
@@ -207,7 +210,8 @@
</div>
</div>
</div>
<div class="bg-gradient-to-br from-gray-800 to-gray-850 rounded-xl p-6 border border-gray-700">
<div class="bg-gradient-to-br from-gray-800 to-gray-850 rounded-xl p-6 border border-gray-700 cursor-pointer hover:border-green-500/50 transition-colors"
onclick="window.location.hash = '#/tags'" title="Click to manage tags">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm font-medium">Unique Tags</p>
@@ -417,6 +421,84 @@
</div>
</div>
</div>
</div><!-- /dashboardView -->
<!-- Tags Page View -->
<div id="tagsPageView" class="hidden flex-1 flex flex-col">
<!-- Tags Header -->
<div class="px-6 py-4 border-b border-gray-700/50 bg-gray-850/50">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<div class="flex items-center space-x-4">
<button onclick="window.location.hash=''" class="text-gray-400 hover:text-white transition-colors text-sm flex items-center space-x-1">
<span>&larr;</span><span>Dashboard</span>
</button>
<h2 class="text-xl font-semibold text-gray-100">Tags</h2>
<span id="tagsTotalBadge" class="text-sm font-medium text-gray-400 bg-gray-700 px-3 py-1 rounded-full">0 tags</span>
</div>
<div id="filterModeToggle" class="hidden items-center space-x-2">
<span class="text-sm text-gray-400">Filter:</span>
<button id="filterModeAnd" class="px-3 py-1 text-xs font-medium rounded bg-green-600 text-white">AND</button>
<button id="filterModeOr" class="px-3 py-1 text-xs font-medium rounded bg-gray-700 text-gray-300 hover:bg-gray-600">OR</button>
</div>
</div>
</div>
<!-- Two-Panel Layout -->
<div class="flex-1 flex overflow-hidden">
<!-- Left Panel: Tag List -->
<div class="w-80 flex-shrink-0 border-r border-gray-700/50 flex flex-col bg-gray-850/30">
<!-- Tag Search & Sort -->
<div class="p-4 space-y-3 border-b border-gray-700/30">
<input type="text" id="tagSearchInput" placeholder="Search tags..."
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500">
<select id="tagSortSelect" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 text-sm">
<option value="alpha_asc">A &rarr; Z</option>
<option value="alpha_desc">Z &rarr; A</option>
<option value="count_desc">Most Used</option>
<option value="count_asc">Least Used</option>
</select>
</div>
<!-- Tag List -->
<div id="tagsListContainer" class="flex-1 overflow-y-auto custom-scrollbar">
<div id="tagsList" class="p-2 space-y-1"></div>
<div id="tagsLoader" class="p-4 text-center hidden">
<div class="w-6 h-6 border-2 border-green-500/30 border-t-green-500 rounded-full animate-spin mx-auto"></div>
</div>
</div>
</div>
<!-- Right Panel: Prompts Display -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Active Tags Pills -->
<div id="activeTagsPills" class="px-6 py-3 border-b border-gray-700/30 hidden">
<div class="flex flex-wrap gap-2 items-center">
<span class="text-sm text-gray-400 mr-1">Filtering:</span>
<div id="tagPillsContainer" class="flex flex-wrap gap-2"></div>
<span id="tagPromptsCountBadge" class="text-xs text-gray-500 ml-auto"></span>
</div>
</div>
<!-- Prompts Grid -->
<div id="tagPromptsContainer" class="flex-1 overflow-y-auto custom-scrollbar">
<div id="tagPromptsGrid" class="p-6 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"></div>
<div id="tagPromptsLoader" class="p-4 text-center hidden">
<div class="w-6 h-6 border-2 border-green-500/30 border-t-green-500 rounded-full animate-spin mx-auto"></div>
</div>
</div>
<!-- Empty State -->
<div id="tagPromptsEmpty" class="flex-1 flex items-center justify-center">
<div class="text-center text-gray-500">
<span class="text-4xl block mb-3">🏷️</span>
<p class="text-lg font-medium">Select a tag to view prompts</p>
<p class="text-sm mt-1">Click a tag from the list or use checkboxes for multi-tag filtering</p>
</div>
</div>
</div>
</div>
<!-- Context Menu for Tag Operations -->
<div id="tagContextMenu" class="hidden fixed z-50 bg-gray-800 border border-gray-600 rounded-lg shadow-xl py-1 min-w-[160px]">
</div>
</div><!-- /tagsPageView -->
</div>
<!-- Settings Modal -->
@@ -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'}`;
}
});
</script>
<script src="/prompt_manager/js/tags-page.js"></script>
</body>
</html>
+994
View File
@@ -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('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80"><rect fill="#374151" width="80" height="80"/><text x="40" y="44" text-anchor="middle" fill="#6B7280" font-size="20">?</text></svg>');
};
// 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;
}