Files
ComfyAssets-ComfyUI_PromptM…/web/admin.html
T
Vito Sansevero a7d842a51c feat(settings): add configurable gallery scan directory
Add ability to configure a custom image scan directory in Settings:
- New "Image Scan Directory" field in Settings modal
- "Currently Monitoring" display shows active directory path
- Settings API now returns/saves gallery_root_path and monitored_directories
- Image monitor checks GalleryConfig before auto-detecting
- Restart notification when gallery path changes

Closes #76
2026-02-02 15:58:54 -08:00

5501 lines
285 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PromptManager - Admin Dashboard</title>
<script src="lib/tailwind/tailwind.js"></script>
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
<script src="lib/viewerjs/viewer.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
gray: {
850: '#1f2937',
875: '#1a202c',
925: '#0f172a'
}
}
}
}
}
</script>
<style>
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #374151;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #6b7280;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
.viewer-dark-theme .viewer-backdrop {
background-color: rgba(0, 0, 0, 0.9) !important;
}
.viewer-dark-theme .viewer-toolbar {
background-color: rgba(31, 41, 55, 0.95) !important;
}
.viewer-dark-theme .viewer-navbar {
background-color: rgba(31, 41, 55, 0.95) !important;
}
.viewer-dark-theme .viewer-title {
color: #f3f4f6 !important;
}
.metadata-sidebar {
position: fixed !important;
top: 0 !important;
right: 0 !important;
width: 384px !important;
height: 100vh !important;
background: rgb(17, 24, 39) !important;
border-left: 1px solid rgb(55, 65, 81) !important;
display: flex !important;
flex-direction: column !important;
z-index: 10000 !important;
}
/* Film Strip Styles */
.prompt-film-strip {
--film-strip-thumbnail-size: 112px;
--film-strip-gap: 8px;
display: flex;
flex-wrap: nowrap;
gap: var(--film-strip-gap);
padding: 8px 0;
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: #4b5563 transparent;
}
.prompt-film-strip::-webkit-scrollbar {
height: 4px;
}
.prompt-film-strip::-webkit-scrollbar-track {
background: transparent;
}
.prompt-film-strip::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 2px;
}
.film-strip-thumbnail {
flex-shrink: 0;
width: var(--film-strip-thumbnail-size);
height: var(--film-strip-thumbnail-size);
border-radius: 6px;
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.15s ease;
background: #1f2937;
}
.film-strip-thumbnail:hover {
border-color: #3b82f6;
transform: scale(1.05);
}
.film-strip-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.film-strip-thumbnail--more {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #374151 0%, #1f2937 100%);
color: #9ca3af;
font-size: 12px;
font-weight: 600;
}
.film-strip-thumbnail--more:hover {
background: linear-gradient(135deg, #4b5563 0%, #374151 100%);
color: #e5e7eb;
}
.film-strip-empty {
color: #6b7280;
font-size: 12px;
font-style: italic;
padding: 8px 0;
}
/* Film strip size variants */
.film-strip-size-small {
--film-strip-thumbnail-size: 72px;
--film-strip-gap: 6px;
}
.film-strip-size-medium {
--film-strip-thumbnail-size: 112px;
--film-strip-gap: 8px;
}
.film-strip-size-large {
--film-strip-thumbnail-size: 144px;
--film-strip-gap: 10px;
}
</style>
</head>
<body class="bg-gray-925 text-gray-100 min-h-screen">
<div class="min-h-screen flex flex-col">
<!-- Header -->
<header class="bg-gradient-to-r from-gray-850 to-gray-800 border-b border-gray-700 px-6 py-4">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
<span class="text-xl">🔍</span>
</div>
<div>
<h1 class="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
PromptManager Admin Dashboard
</h1>
<p class="text-gray-400 text-sm">Manage and organize your AI prompts with powerful search and editing tools</p>
</div>
</div>
<div class="hidden sm:flex items-center space-x-2">
<div class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
<span class="text-green-400 text-sm font-medium">Online</span>
</div>
</div>
</div>
</header>
<!-- Stats Grid -->
<div class="px-6 py-6">
<div class="max-w-7xl mx-auto">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-gradient-to-br from-gray-800 to-gray-850 rounded-xl p-6 border border-gray-700">
<div class="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm font-medium">Total Prompts</p>
<p class="text-3xl font-bold text-blue-400" id="totalPrompts">-</p>
</div>
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
<span class="text-2xl">📝</span>
</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="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm font-medium">Categories</p>
<p class="text-3xl font-bold text-purple-400" id="totalCategories">-</p>
</div>
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
<span class="text-2xl">📁</span>
</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="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm font-medium">Unique Tags</p>
<p class="text-3xl font-bold text-green-400" id="totalTags">-</p>
</div>
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
<span class="text-2xl">🏷️</span>
</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="flex items-center justify-between">
<div>
<p class="text-gray-400 text-sm font-medium">Avg Rating</p>
<p class="text-3xl font-bold text-yellow-400" id="avgRating">-</p>
</div>
<div class="w-12 h-12 bg-yellow-500/20 rounded-lg flex items-center justify-center">
<span class="text-2xl">⭐</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Search Controls (Sticky) -->
<div class="sticky top-0 z-50 bg-gray-925 bg-opacity-95 backdrop-blur-sm border-b border-gray-700/50">
<div class="px-6 py-4">
<div class="max-w-7xl mx-auto">
<div class="bg-gradient-to-br from-gray-800 to-gray-850 rounded-xl p-6 border border-gray-700">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4 mb-6">
<div class="lg:col-span-2">
<label class="block text-sm font-medium text-gray-400 mb-2">Search Text</label>
<input type="text" id="searchText" placeholder="Search prompt content..."
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-100 placeholder-gray-400">
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">Category</label>
<select id="searchCategory"
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-100">
<option value="">All Categories</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">Tags</label>
<input type="text" id="searchTags" placeholder="tag1, tag2..."
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-100 placeholder-gray-400">
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">Sort By</label>
<select id="sortBy"
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-100">
<option value="created_desc">Newest First</option>
<option value="created_asc">Oldest First</option>
<option value="rating_desc">Highest Rated</option>
<option value="rating_asc">Lowest Rated</option>
<option value="text_asc">A-Z</option>
<option value="text_desc">Z-A</option>
</select>
</div>
<div class="flex items-end">
<button id="searchBtn"
class="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-medium py-3 px-6 rounded-lg transition-all duration-200 flex items-center justify-center space-x-2">
<span>🔍</span>
<span>Search</span>
</button>
</div>
</div>
<!-- Bulk Actions -->
<div class="flex flex-wrap items-center gap-3 pt-4 border-t border-gray-700">
<label class="flex items-center space-x-2 text-gray-300">
<input type="checkbox" id="selectAll" class="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500">
<span class="text-sm font-medium">Select All</span>
</label>
<button id="bulkDeleteBtn" disabled
class="px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors">
🗑️ Delete Selected
</button>
<button id="bulkTagBtn" disabled
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors">
🏷️ Add Tags
</button>
<button id="bulkCategoryBtn" disabled
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors">
📁 Set Category
</button>
<button id="addPromptBtn"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-medium rounded-lg transition-colors">
➕ Add Prompt
</button>
<button id="statsBtn"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors">
📊 Stats
</button>
<button id="exportBtn"
class="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white text-sm font-medium rounded-lg transition-colors">
📄 Export
</button>
<button id="settingsBtn"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm font-medium rounded-lg transition-colors">
⚙️ Settings
</button>
<button id="diagnosticsBtn"
class="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white text-sm font-medium rounded-lg transition-colors">
🔍 Diagnostics
</button>
<button id="maintenanceBtn"
class="px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium rounded-lg transition-colors">
🔧 Maintenance
</button>
<button id="backupBtn"
class="px-4 py-2 bg-sky-600 hover:bg-sky-700 text-white text-sm font-medium rounded-lg transition-colors">
💾 Backup DB
</button>
<button id="restoreBtn"
class="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium rounded-lg transition-colors">
📁 Restore DB
</button>
<button id="scanBtn"
class="px-4 py-2 bg-pink-600 hover:bg-pink-700 text-white text-sm font-medium rounded-lg transition-colors">
📸 Scan Images
</button>
<button id="autoTagBtn"
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium rounded-lg transition-colors">
🏷️ Auto Tag
</button>
<button id="logsBtn"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors">
📋 View Logs
</button>
<button id="metadataBtn"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
🔍 Metadata
</button>
<button id="galleryBtn"
class="px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white text-sm font-medium rounded-lg transition-colors">
🖼️ Gallery
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Results Section -->
<div class="flex-1 px-6 pb-6">
<div class="max-w-7xl mx-auto h-full">
<div class="bg-gradient-to-br from-gray-800 to-gray-850 rounded-xl border border-gray-700 h-full flex flex-col">
<!-- Results Header -->
<div class="flex items-center justify-between p-6 border-b border-gray-700">
<div class="flex items-center space-x-4">
<h2 class="text-xl font-semibold text-gray-100" id="resultsTitle">Recent Prompts</h2>
<div class="flex items-center space-x-2">
<label class="text-sm text-gray-400">Show:</label>
<select id="limitSelector" class="px-3 py-1 bg-gray-700 border border-gray-600 rounded text-gray-100 text-sm">
<option value="25">25</option>
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="200">200</option>
<option value="500">500</option>
</select>
<span class="text-sm text-gray-400">per page</span>
</div>
</div>
<span class="text-sm font-medium text-gray-400 bg-gray-700 px-3 py-1 rounded-full" id="resultsCount">Loading...</span>
</div>
<!-- Loading State -->
<div id="loadingState" class="flex-1 flex items-center justify-center">
<div class="text-center">
<div class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mx-auto mb-4"></div>
<p class="text-gray-400">Loading prompts...</p>
</div>
</div>
<!-- Results List -->
<div id="resultsList" class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 hidden">
<!-- Prompts will be inserted here -->
</div>
<!-- Pagination Controls -->
<div id="paginationControls" class="border-t border-gray-700 p-4 hidden">
<div class="flex items-center justify-between">
<div class="text-sm text-gray-400">
Showing <span id="showingStart">1</span> to <span id="showingEnd">50</span> of <span id="totalResults">0</span> prompts
</div>
<div class="flex items-center space-x-2">
<button id="firstPageBtn" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed text-sm">
First
</button>
<button id="prevPageBtn" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed text-sm">
Previous
</button>
<span class="text-sm text-gray-400">
Page <span id="currentPage">1</span> of <span id="totalPages">1</span>
</span>
<button id="nextPageBtn" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed text-sm">
Next
</button>
<button id="lastPageBtn" class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed text-sm">
Last
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settingsModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-lg w-full mx-4 border border-gray-700 max-h-[90vh] overflow-y-auto">
<h3 class="text-xl font-semibold text-gray-100 mb-6">⚙️ Settings</h3>
<div class="space-y-6">
<!-- Gallery Settings Section -->
<div class="border-b border-gray-700 pb-4">
<h4 class="text-sm font-semibold text-gray-300 mb-4 uppercase tracking-wide">📁 Gallery Settings</h4>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-400 mb-2">Image Scan Directory</label>
<input type="text" id="galleryRootPath" placeholder="Leave empty for auto-detect"
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-100">
<p class="text-xs text-gray-500 mt-2">Custom path to scan for images. Leave empty to use ComfyUI's default output directory.</p>
</div>
<div class="mb-4">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-gray-400">Currently Monitoring</label>
<button id="refreshMonitoringStatus" class="text-xs text-blue-400 hover:text-blue-300">🔄 Refresh</button>
</div>
<div id="monitoringStatus" class="mt-2 px-3 py-2 bg-gray-900 rounded-lg text-xs text-gray-400 font-mono">
Loading...
</div>
</div>
</div>
<!-- UI Settings Section -->
<div class="border-b border-gray-700 pb-4">
<h4 class="text-sm font-semibold text-gray-300 mb-4 uppercase tracking-wide">🖥️ UI Settings</h4>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-400 mb-2">Search Results Auto-Hide (seconds)</label>
<input type="number" id="resultTimeout" min="0" max="300" value="30"
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-100">
<p class="text-xs text-gray-500 mt-2">Set to 0 to disable auto-hide. Controls how long search results stay visible in the ComfyUI node.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">Web UI Display Mode</label>
<select id="webuiDisplayMode"
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-100">
<option value="popup">Popup Window (Current)</option>
<option value="newtab">New Tab</option>
</select>
<p class="text-xs text-gray-500 mt-2">Choose how the Web UI opens when clicked from ComfyUI nodes.</p>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-8">
<button id="cancelSettings"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
<button id="saveSettings"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
Save Settings
</button>
</div>
</div>
</div>
<!-- Bulk Tag Modal -->
<div id="bulkTagModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-6">🏷️ Add Tags to Selected Prompts</h3>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">Tags (comma-separated)</label>
<input type="text" id="bulkTagInput" placeholder="tag1, tag2, tag3..."
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-100">
</div>
<div class="flex justify-end space-x-3 mt-6">
<button id="cancelBulkTag"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
<button id="confirmBulkTag"
class="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors">
Add Tags
</button>
</div>
</div>
</div>
<!-- Individual Tag Modal -->
<div id="individualTagModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-6">🏷️ Add Tags to Prompt</h3>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">Tags (comma-separated)</label>
<input type="text" id="individualTagInput" placeholder="tag1, tag2, tag3..."
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-100">
</div>
<div class="flex justify-end space-x-3 mt-6">
<button id="cancelIndividualTag"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
<button id="confirmIndividualTag"
class="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors">
Add Tags
</button>
</div>
</div>
</div>
<!-- Bulk Category Modal -->
<div id="bulkCategoryModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-6">📁 Set Category for Selected Prompts</h3>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">Category</label>
<input type="text" id="bulkCategoryInput" placeholder="Enter category name..."
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-100">
</div>
<div class="flex justify-end space-x-3 mt-6">
<button id="cancelBulkCategory"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
<button id="confirmBulkCategory"
class="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors">
Set Category
</button>
</div>
</div>
</div>
<!-- Database Restore Modal -->
<div id="restoreModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-6">📁 Restore Database</h3>
<div class="mb-4">
<div class="bg-yellow-900/20 border border-yellow-600 rounded-lg p-4 mb-4">
<div class="flex items-start space-x-2">
<span class="text-yellow-400 text-xl">⚠️</span>
<div>
<h4 class="text-yellow-400 font-medium">Warning</h4>
<p class="text-gray-300 text-sm">This will replace your current database. A backup will be created automatically.</p>
</div>
</div>
</div>
<label class="block text-sm font-medium text-gray-400 mb-2">Select Database File (.db)</label>
<input type="file" id="restoreFileInput" accept=".db"
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-100 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
</div>
<div class="flex justify-end space-x-3 mt-6">
<button id="cancelRestore"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
<button id="confirmRestore"
class="px-6 py-2 bg-amber-600 hover:bg-amber-700 text-white font-medium rounded-lg transition-colors">
Restore Database
</button>
</div>
</div>
</div>
<!-- Diagnostics Modal -->
<div id="diagnosticsModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl max-w-4xl w-full max-h-[90vh] overflow-hidden mx-4 border border-gray-700">
<div class="p-6 border-b border-gray-700">
<div class="flex items-center justify-between">
<h3 class="text-xl font-semibold text-gray-100">🔍 Gallery System Diagnostics</h3>
<button onclick="window.admin.closeDiagnostics()" class="p-2 rounded-lg hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<div class="p-6 overflow-y-auto max-h-[calc(90vh-120px)] custom-scrollbar">
<div class="flex items-center justify-between mb-4">
<button id="runDiagnosticsBtn" class="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white font-medium rounded-lg transition-colors">
🔍 Run Diagnostics
</button>
<button id="testImageLinkBtn" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
🧪 Test Image Link
</button>
</div>
<div id="diagnosticsContent" class="space-y-4">
<div class="text-center py-8 text-gray-400">
Click "Run Diagnostics" to check the gallery system status
</div>
</div>
</div>
</div>
</div>
<!-- Maintenance Modal -->
<div id="maintenanceModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl max-w-4xl w-full max-h-[90vh] overflow-hidden mx-4 border border-gray-700">
<div class="p-6 border-b border-gray-700">
<div class="flex items-center justify-between">
<h3 class="text-xl font-semibold text-gray-100">🔧 Database Maintenance</h3>
<button onclick="window.admin.closeMaintenance()" class="p-2 rounded-lg hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<div class="p-6 overflow-y-auto max-h-[calc(90vh-120px)] custom-scrollbar">
<!-- Maintenance Options -->
<div class="space-y-4 mb-6">
<h4 class="text-lg font-medium text-gray-100 mb-4">Select Maintenance Operations:</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="flex items-center space-x-3 p-4 bg-gray-700 rounded-lg hover:bg-gray-600 cursor-pointer">
<input type="checkbox" id="cleanup_duplicates" class="maintenance-option w-4 h-4 text-teal-600 bg-gray-600 border-gray-500 rounded focus:ring-teal-500" checked>
<div class="flex-1">
<div class="text-white font-medium">🗑️ Remove Duplicates</div>
<div class="text-sm text-gray-400">Find and remove duplicate prompts based on text content</div>
</div>
</label>
<label class="flex items-center space-x-3 p-4 bg-gray-700 rounded-lg hover:bg-gray-600 cursor-pointer">
<input type="checkbox" id="vacuum" class="maintenance-option w-4 h-4 text-teal-600 bg-gray-600 border-gray-500 rounded focus:ring-teal-500" checked>
<div class="flex-1">
<div class="text-white font-medium">⚡ Vacuum Database</div>
<div class="text-sm text-gray-400">Optimize database file size and performance</div>
</div>
</label>
<label class="flex items-center space-x-3 p-4 bg-gray-700 rounded-lg hover:bg-gray-600 cursor-pointer">
<input type="checkbox" id="cleanup_orphaned_images" class="maintenance-option w-4 h-4 text-teal-600 bg-gray-600 border-gray-500 rounded focus:ring-teal-500" checked>
<div class="flex-1">
<div class="text-white font-medium">🖼️ Clean Orphaned Images</div>
<div class="text-sm text-gray-400">Remove image records for files that no longer exist</div>
</div>
</label>
<label class="flex items-center space-x-3 p-4 bg-gray-700 rounded-lg hover:bg-gray-600 cursor-pointer">
<input type="checkbox" id="check_hash_duplicates" class="maintenance-option w-4 h-4 text-teal-600 bg-gray-600 border-gray-500 rounded focus:ring-teal-500">
<div class="flex-1">
<div class="text-white font-medium">🔍 Check Hash Duplicates</div>
<div class="text-sm text-gray-400">Identify prompts with duplicate hash values</div>
</div>
</label>
<label class="flex items-center space-x-3 p-4 bg-gray-700 rounded-lg hover:bg-gray-600 cursor-pointer">
<input type="checkbox" id="check_consistency" class="maintenance-option w-4 h-4 text-teal-600 bg-gray-600 border-gray-500 rounded focus:ring-teal-500">
<div class="flex-1">
<div class="text-white font-medium">🔧 Consistency Check</div>
<div class="text-sm text-gray-400">Verify database integrity and data consistency</div>
</div>
</label>
<label class="flex items-center space-x-3 p-4 bg-gray-700 rounded-lg hover:bg-gray-600 cursor-pointer">
<input type="checkbox" id="statistics" class="maintenance-option w-4 h-4 text-teal-600 bg-gray-600 border-gray-500 rounded focus:ring-teal-500">
<div class="flex-1">
<div class="text-white font-medium">📊 Database Statistics</div>
<div class="text-sm text-gray-400">Generate detailed database statistics report</div>
</div>
</label>
<label class="flex items-center space-x-3 p-4 bg-gray-700 rounded-lg hover:bg-gray-600 cursor-pointer">
<input type="checkbox" id="prune_orphaned_prompts" class="maintenance-option w-4 h-4 text-teal-600 bg-gray-600 border-gray-500 rounded focus:ring-teal-500">
<div class="flex-1">
<div class="text-white font-medium">🚮 Prune Orphaned Prompts</div>
<div class="text-sm text-gray-400">Remove prompts that have no linked images</div>
</div>
</label>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center justify-between mb-6">
<div class="flex space-x-3">
<button id="runMaintenanceBtn" class="px-6 py-3 bg-teal-600 hover:bg-teal-700 text-white font-medium rounded-lg transition-colors">
🔧 Run Maintenance
</button>
<button id="selectAllMaintenanceBtn" class="px-4 py-3 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
✅ Select All
</button>
<button id="clearAllMaintenanceBtn" class="px-4 py-3 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
❌ Clear All
</button>
</div>
</div>
<!-- Results -->
<div id="maintenanceResults" class="space-y-4">
<div class="text-center py-8 text-gray-400">
Click "Run Maintenance" to perform selected operations
</div>
</div>
</div>
</div>
</div>
<!-- Gallery Modal -->
<div id="galleryModal" class="fixed inset-0 bg-black bg-opacity-75 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl max-w-6xl w-full max-h-[90vh] overflow-hidden mx-4 border border-gray-700">
<div class="p-6 border-b border-gray-700">
<div class="flex items-center justify-between">
<h3 class="text-xl font-semibold text-gray-100" id="galleryTitle">Generated Images</h3>
<button onclick="window.admin.closeGallery()" class="p-2 rounded-lg hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<div class="p-6 overflow-y-auto max-h-[calc(90vh-120px)] custom-scrollbar">
<div id="galleryContent" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Gallery images will be loaded here -->
</div>
<div id="galleryEmpty" class="text-center py-12 hidden">
<div class="w-16 h-16 bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4">
<span class="text-2xl">🖼️</span>
</div>
<h4 class="text-lg font-medium text-gray-300 mb-2">No images found</h4>
<p class="text-gray-500">No images have been generated with this prompt yet.</p>
</div>
<div id="galleryLoading" class="text-center py-12">
<div class="w-12 h-12 border-4 border-purple-500/30 border-t-purple-500 rounded-full animate-spin mx-auto mb-4"></div>
<p class="text-gray-400">Loading images...</p>
</div>
</div>
</div>
</div>
<!-- Scan Modal -->
<div id="scanModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-lg w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-6">📸 Scan ComfyUI Output Images</h3>
<div class="space-y-4">
<div class="bg-orange-900/30 border border-orange-600 rounded-lg p-4">
<div class="flex items-start space-x-3">
<span class="text-orange-400 text-xl">⚠️</span>
<div>
<h4 class="text-orange-300 font-medium mb-2">Important Information</h4>
<ul class="text-orange-200 text-sm space-y-1">
<li>• This will scan all PNG files in your ComfyUI output directory</li>
<li>• Depending on the size of your output folder, this could take several minutes</li>
<li>• This is an experimental feature - we recommend backing up your database first</li>
<li>• Only images with ComfyUI metadata will be processed</li>
<li>• Duplicate prompts will be automatically detected and skipped</li>
</ul>
</div>
</div>
</div>
<div class="bg-blue-900/30 border border-blue-600 rounded-lg p-4">
<div class="flex items-center justify-between">
<span class="text-blue-300 font-medium">Backup Database First (Recommended)</span>
<button id="quickBackupBtn"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
💾 Backup Now
</button>
</div>
</div>
<div id="scanProgress" class="hidden space-y-3">
<div class="bg-gray-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-gray-300 text-sm font-medium">Scanning Progress</span>
<span id="scanStatusText" class="text-gray-400 text-sm">Preparing...</span>
</div>
<div class="w-full bg-gray-600 rounded-full h-2">
<div id="scanProgressBar" class="bg-pink-600 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div class="flex justify-between text-xs text-gray-400 mt-2">
<span id="scanCount">0 files processed</span>
<span id="scanFound">0 prompts found</span>
</div>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button id="cancelScan"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
<button id="startScan"
class="px-6 py-2 bg-pink-600 hover:bg-pink-700 text-white font-medium rounded-lg transition-colors">
Start Scan
</button>
</div>
</div>
</div>
<!-- Logs Modal -->
<div id="logsModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl max-w-6xl w-full max-h-[90vh] overflow-hidden mx-4 border border-gray-700">
<div class="p-6 border-b border-gray-700">
<div class="flex items-center justify-between">
<h3 class="text-xl font-semibold text-gray-100">📋 System Logs</h3>
<button onclick="window.admin.closeLogs()" class="p-2 rounded-lg hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<!-- Log Controls -->
<div class="p-6 border-b border-gray-700">
<div class="grid grid-cols-1 md:grid-cols-5 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">Log Level</label>
<select id="logLevel" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-100">
<option value="">All Levels</option>
<option value="DEBUG">Debug</option>
<option value="INFO">Info</option>
<option value="WARNING">Warning</option>
<option value="ERROR">Error</option>
<option value="CRITICAL">Critical</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">Limit</label>
<select id="logLimit" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-100">
<option value="50">Last 50</option>
<option value="100" selected>Last 100</option>
<option value="200">Last 200</option>
<option value="500">Last 500</option>
</select>
</div>
<div class="flex items-end">
<button id="refreshLogsBtn" class="w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
🔄 Refresh
</button>
</div>
<div class="flex items-end">
<button id="downloadLogsBtn" class="w-full px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
📥 Download
</button>
</div>
<div class="flex items-end">
<button id="clearLogsBtn" class="w-full px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
🗑️ Clear Logs
</button>
</div>
</div>
<!-- Log Stats -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-4">
<div class="bg-gray-700 rounded-lg p-3">
<div class="text-xs text-gray-400">Total Logs</div>
<div class="text-lg font-semibold text-blue-400" id="logStatsTotal">-</div>
</div>
<div class="bg-gray-700 rounded-lg p-3">
<div class="text-xs text-gray-400">Errors</div>
<div class="text-lg font-semibold text-red-400" id="logStatsErrors">-</div>
</div>
<div class="bg-gray-700 rounded-lg p-3">
<div class="text-xs text-gray-400">Warnings</div>
<div class="text-lg font-semibold text-yellow-400" id="logStatsWarnings">-</div>
</div>
<div class="bg-gray-700 rounded-lg p-3">
<div class="text-xs text-gray-400">Current Level</div>
<div class="text-lg font-semibold text-green-400" id="logStatsLevel">-</div>
</div>
<div class="bg-gray-700 rounded-lg p-3">
<div class="text-xs text-gray-400">Log Size</div>
<div class="text-lg font-semibold text-purple-400" id="logStatsSize">-</div>
</div>
</div>
<!-- Log Settings -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">Set Log Level</label>
<select id="setLogLevel" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-100">
<option value="DEBUG">Debug</option>
<option value="INFO" selected>Info</option>
<option value="WARNING">Warning</option>
<option value="ERROR">Error</option>
<option value="CRITICAL">Critical</option>
</select>
</div>
<div class="flex items-center">
<label class="flex items-center space-x-2 text-gray-300">
<input type="checkbox" id="consoleLogging" checked class="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500">
<span class="text-sm">Console Logging</span>
</label>
</div>
<div class="flex items-end">
<button id="updateLogConfigBtn" class="w-full px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors">
💾 Update Config
</button>
</div>
</div>
</div>
<!-- Log Viewer -->
<div class="flex-1 overflow-hidden">
<div class="h-96 overflow-y-auto custom-scrollbar bg-gray-900 font-mono text-sm">
<div id="logsContainer" class="p-4 space-y-1">
<div class="text-center py-8 text-gray-500">
Click "Refresh" to load logs
</div>
</div>
</div>
</div>
<!-- Log Files -->
<div class="p-6 border-t border-gray-700">
<h4 class="text-lg font-medium text-gray-100 mb-4">Log Files</h4>
<div id="logFilesList" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<!-- Log files will be inserted here -->
</div>
</div>
</div>
</div>
<!-- Add Prompt Modal -->
<div id="addPromptModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-2xl w-full mx-4 border border-gray-700 max-h-[90vh] overflow-y-auto custom-scrollbar">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold text-gray-100">➕ Add New Prompt</h3>
<button onclick="window.admin.hideModal('addPromptModal')" class="p-2 rounded-lg hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- Prompt Text -->
<div class="mb-5">
<label class="block text-sm font-medium text-gray-400 mb-2">Prompt Text <span class="text-red-400">*</span></label>
<textarea id="addPromptText" rows="4"
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-gray-100 text-sm resize-none custom-scrollbar"
placeholder="Enter your prompt text here..."></textarea>
</div>
<!-- Category and Rating Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-5">
<!-- Category -->
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">Category</label>
<div class="relative">
<input type="text" id="addPromptCategory" list="addPromptCategoryList"
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-gray-100 text-sm"
placeholder="Select or type category...">
<datalist id="addPromptCategoryList"></datalist>
</div>
</div>
<!-- Rating -->
<div>
<label class="block text-sm font-medium text-gray-400 mb-2">Rating</label>
<div class="flex items-center space-x-1" id="addPromptRating">
<button type="button" class="rating-star text-2xl text-gray-500 hover:text-yellow-400 transition-colors" data-rating="1">☆</button>
<button type="button" class="rating-star text-2xl text-gray-500 hover:text-yellow-400 transition-colors" data-rating="2">☆</button>
<button type="button" class="rating-star text-2xl text-gray-500 hover:text-yellow-400 transition-colors" data-rating="3">☆</button>
<button type="button" class="rating-star text-2xl text-gray-500 hover:text-yellow-400 transition-colors" data-rating="4">☆</button>
<button type="button" class="rating-star text-2xl text-gray-500 hover:text-yellow-400 transition-colors" data-rating="5">☆</button>
<button type="button" class="ml-2 text-xs text-gray-500 hover:text-gray-300" id="clearAddPromptRatingBtn">Clear</button>
</div>
<input type="hidden" id="addPromptRatingValue" value="">
</div>
</div>
<!-- Tags -->
<div class="mb-5">
<label class="block text-sm font-medium text-gray-400 mb-2">Tags</label>
<div class="relative">
<div id="addPromptTagsContainer" class="flex flex-wrap gap-2 p-3 bg-gray-700 border border-gray-600 rounded-lg min-h-[44px]">
<input type="text" id="addPromptTagInput"
class="flex-1 min-w-[150px] bg-transparent border-none outline-none text-gray-100 text-sm placeholder-gray-500"
placeholder="Type to add tags...">
</div>
<div id="addPromptTagSuggestions" class="absolute left-0 right-0 top-full mt-1 bg-gray-700 border border-gray-600 rounded-lg shadow-lg hidden z-10 max-h-48 overflow-y-auto custom-scrollbar"></div>
</div>
<p class="text-gray-500 text-xs mt-1">Press Enter or comma to add a tag. Click a tag to remove it.</p>
</div>
<!-- Notes -->
<div class="mb-5">
<label class="block text-sm font-medium text-gray-400 mb-2">Notes</label>
<textarea id="addPromptNotes" rows="2"
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500 text-gray-100 text-sm resize-none custom-scrollbar"
placeholder="Optional notes about this prompt..."></textarea>
</div>
<!-- Protection Option -->
<div class="mb-6">
<label class="flex items-start p-4 bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-650 border border-gray-600">
<input type="checkbox" id="addPromptProtected" checked class="w-4 h-4 mt-0.5 text-green-600 bg-gray-700 border-gray-600 rounded focus:ring-green-500">
<div class="ml-3">
<span class="text-gray-100 font-medium">Protect from orphan cleanup</span>
<p class="text-gray-400 text-sm mt-1">Keep this prompt even if it has no linked images. Uncheck if you want this prompt to be automatically removed during maintenance when it has no images.</p>
</div>
</label>
</div>
<!-- Action Buttons -->
<div class="flex justify-end space-x-3">
<button onclick="window.admin.hideModal('addPromptModal')"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
<button id="saveNewPromptBtn"
class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
➕ Add Prompt
</button>
</div>
</div>
</div>
<!-- Auto Tag Modal -->
<div id="autoTagModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-lg w-full mx-4 border border-gray-700">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold text-gray-100">🏷️ Auto Tag Images</h3>
<button onclick="hideModal('autoTagModal')" class="p-2 rounded-lg hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- Model Selection -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-400 mb-3">Model Selection</label>
<div class="space-y-3">
<label class="flex items-center justify-between p-4 bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-650 border border-gray-600">
<div class="flex items-center">
<input type="radio" name="autoTagModel" value="gguf" checked class="w-4 h-4 text-purple-600 bg-gray-700 border-gray-600 focus:ring-purple-500">
<div class="ml-3">
<span class="text-gray-100 font-medium">GGUF Model</span>
<span class="text-gray-400 text-sm ml-2">(Recommended, ~4.5GB)</span>
</div>
</div>
<div id="ggufModelStatus" class="flex items-center space-x-2">
<span class="text-gray-400 text-sm">Checking...</span>
</div>
</label>
<label class="flex items-center justify-between p-4 bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-650 border border-gray-600">
<div class="flex items-center">
<input type="radio" name="autoTagModel" value="hf" class="w-4 h-4 text-purple-600 bg-gray-700 border-gray-600 focus:ring-purple-500">
<div class="ml-3">
<span class="text-gray-100 font-medium">HuggingFace Model</span>
<span class="text-gray-400 text-sm ml-2">(~16GB)</span>
</div>
</div>
<div id="hfModelStatus" class="flex items-center space-x-2">
<span class="text-gray-400 text-sm">Checking...</span>
</div>
</label>
</div>
</div>
<!-- Prompt Template -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-400 mb-2">Tag Generation Prompt</label>
<textarea id="autoTagPrompt" rows="6"
class="w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-gray-100 text-sm font-mono resize-none custom-scrollbar"
placeholder="Enter prompt template...">Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).
Include: subject_count (1girl/1boy/etc), gender, ethnicity (asian, caucasian, etc), age_approx (teen, young_woman), body_attributes, face_features, hairstyle, appearance, clothing, accessories, pose, expression, action, camera_angle, camera_shot, scene_composition (wide_shot, long_shot, dutch_angle), environment, background_elements, lighting, mood.
Prioritize visual accuracy.
No sentences, no commentary, no captions. Only tags. Keep length short but descriptive.</textarea>
</div>
<!-- Tagged Images Handling (for Auto Tag mode) -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-400 mb-3">Already Tagged Images</label>
<div class="space-y-2">
<label class="flex items-center p-3 bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-650 border border-gray-600">
<input type="radio" name="autoTagMode" value="skip" checked class="w-4 h-4 text-purple-600 bg-gray-700 border-gray-600 focus:ring-purple-500">
<span class="ml-3 text-gray-100">Skip images with existing tags</span>
</label>
<label class="flex items-center p-3 bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-650 border border-gray-600">
<input type="radio" name="autoTagMode" value="retag" class="w-4 h-4 text-purple-600 bg-gray-700 border-gray-600 focus:ring-purple-500">
<span class="ml-3 text-gray-100">Re-tag all images</span>
</label>
</div>
<p class="text-gray-500 text-xs mt-2">Note: "auto-scanned" is not counted as a real tag</p>
</div>
<!-- Action Buttons -->
<div class="flex justify-end space-x-3">
<button onclick="hideModal('autoTagModal')"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
<button id="startReviewBtn"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
👁️ Review
</button>
<button id="startAutoTagBtn"
class="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors">
🏷️ Auto Tag
</button>
</div>
</div>
</div>
<!-- Auto Tag Review Modal -->
<div id="autoTagReviewModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-4xl w-full mx-4 border border-gray-700">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold text-gray-100">
👁️ Review Tags
<span class="text-gray-400 text-base font-normal">(<span id="reviewCurrentIndex">1</span> of <span id="reviewTotalCount">0</span>)</span>
</h3>
<button onclick="window.admin.cancelReview()" class="p-2 rounded-lg hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Image Preview -->
<div class="bg-gray-900 rounded-lg p-4">
<img id="reviewImage" src="" alt="Review image" class="w-full h-64 object-contain rounded">
<p id="reviewImageName" class="text-gray-400 text-sm mt-2 text-center truncate">image_name.png</p>
</div>
<!-- Generated Tags -->
<div>
<label class="block text-sm font-medium text-gray-400 mb-3">Generated Tags (click to remove)</label>
<div id="reviewTagsContainer" class="p-4 bg-gray-900 rounded-lg">
<!-- Visible tags (first row) -->
<div id="reviewTagsVisible" class="flex flex-wrap gap-2">
<!-- First row of tags -->
</div>
<!-- Accordion for remaining tags -->
<div id="reviewTagsAccordion" class="hidden mt-3">
<div id="reviewTagsHidden" class="flex flex-wrap gap-2 max-h-[180px] overflow-y-auto custom-scrollbar">
<!-- Remaining tags -->
</div>
</div>
<!-- Toggle button -->
<button id="reviewTagsToggle" class="hidden mt-3 text-sm text-blue-400 hover:text-blue-300 transition-colors flex items-center gap-1">
<span id="reviewTagsToggleIcon">▼</span>
<span id="reviewTagsToggleText">Show all tags</span>
<span id="reviewTagsCount" class="text-gray-500"></span>
</button>
</div>
<p class="text-gray-500 text-xs mt-2">Click the × on tags to remove them before applying</p>
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-between mt-6">
<button id="skipReviewBtn"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
⏭️ Skip
</button>
<div class="flex space-x-3">
<button onclick="window.admin.cancelReview()"
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
Cancel All
</button>
<button id="applyReviewBtn"
class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
✅ Apply & Next
</button>
</div>
</div>
</div>
</div>
<!-- Auto Tag Progress Modal -->
<div id="autoTagProgressModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-6">🏷️ Auto Tagging Progress</h3>
<!-- Current File -->
<div class="mb-4">
<p class="text-gray-400 text-sm">Processing:</p>
<p id="autoTagCurrentFile" class="text-gray-100 font-medium truncate">Initializing...</p>
</div>
<!-- Progress Bar -->
<div class="mb-6">
<div class="flex justify-between text-sm text-gray-400 mb-2">
<span>Progress</span>
<span id="autoTagProgressPercent">0%</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-3">
<div id="autoTagProgressBar" class="bg-purple-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-gray-700 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-purple-400" id="autoTagProcessed">0</div>
<div class="text-xs text-gray-400">Processed</div>
</div>
<div class="bg-gray-700 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-green-400" id="autoTagApplied">0</div>
<div class="text-xs text-gray-400">Tags Applied</div>
</div>
<div class="bg-gray-700 rounded-lg p-3 text-center">
<div class="text-2xl font-bold text-yellow-400" id="autoTagSkipped">0</div>
<div class="text-xs text-gray-400">Skipped</div>
</div>
</div>
<!-- Cancel Button -->
<div class="flex justify-center">
<button id="cancelAutoTagBtn"
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
</div>
</div>
</div>
<!-- Auto Tag Loading Model Modal -->
<div id="autoTagLoadingModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-700 text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<h3 class="text-xl font-semibold text-gray-100 mb-2">Loading Model</h3>
<p id="autoTagLoadingStatus" class="text-gray-400">Initializing...</p>
<p class="text-gray-500 text-sm mt-4">This may take 30-60 seconds on first load</p>
</div>
</div>
<!-- Re-tag Confirmation Modal -->
<div id="retagConfirmModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-lg w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-4">🏷️ Image Already Tagged</h3>
<!-- Image preview -->
<div class="mb-4">
<img id="retagPreviewImage" src="" alt="Preview" class="w-full h-48 object-contain bg-gray-900 rounded-lg">
<p id="retagImageName" class="text-gray-400 text-sm mt-2 text-center truncate"></p>
</div>
<!-- Existing tags -->
<div class="mb-4">
<p class="text-gray-300 text-sm mb-2">Current tags:</p>
<div id="retagExistingTags" class="flex flex-wrap gap-1 max-h-24 overflow-y-auto">
<!-- Tags will be inserted here -->
</div>
</div>
<p class="text-gray-400 mb-6">This image already has tags. Would you like to generate new tags?</p>
<!-- Buttons -->
<div class="flex gap-3 justify-end">
<button id="retagSkipBtn"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white font-medium rounded-lg transition-colors">
Skip
</button>
<button id="retagSkipAllBtn"
class="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white font-medium rounded-lg transition-colors">
Skip All Tagged
</button>
<button id="retagConfirmBtn"
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors">
Re-tag
</button>
</div>
</div>
</div>
<!-- Auto Tag Download Modal -->
<div id="autoTagDownloadModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-gray-800 rounded-xl p-6 max-w-md w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-6">📥 Downloading Model</h3>
<!-- Model Name -->
<div class="mb-4">
<p class="text-gray-400 text-sm">Downloading:</p>
<p id="downloadModelName" class="text-gray-100 font-medium">GGUF Model</p>
</div>
<!-- Progress Bar -->
<div class="mb-6">
<div class="flex justify-between text-sm text-gray-400 mb-2">
<span id="downloadStatus">Preparing...</span>
<span id="downloadProgressPercent">0%</span>
</div>
<div class="w-full bg-gray-700 rounded-full h-3">
<div id="downloadProgressBar" class="bg-blue-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
</div>
<!-- Cancel Button -->
<div class="flex justify-center">
<button id="cancelDownloadBtn"
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
Cancel
</button>
</div>
</div>
</div>
<style>
/* Tag chip styles for Auto Tag feature */
.tag-chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
background-color: #374151;
border: 1px solid #4B5563;
border-radius: 0.375rem;
font-size: 0.875rem;
color: #D1D5DB;
transition: all 0.2s;
}
.tag-chip:hover {
background-color: #4B5563;
}
.tag-chip .tag-remove {
margin-left: 0.375rem;
padding: 0.125rem;
border-radius: 0.25rem;
cursor: pointer;
color: #9CA3AF;
transition: all 0.2s;
}
.tag-chip .tag-remove:hover {
color: #EF4444;
background-color: rgba(239, 68, 68, 0.2);
}
.hover\:bg-gray-650:hover {
background-color: #525252;
}
</style>
<script>
class PromptAdmin {
constructor() {
this.prompts = [];
this.selectedPrompts = new Set();
this.settings = {
resultTimeout: 5,
webuiDisplayMode: 'popup',
};
this.categories = [];
this.tags = [];
this.imageViewMode = 'fit'; // 'fit' or 'full'
this.naturalImageSize = { width: 0, height: 0 };
// Pagination state
this.pagination = {
currentPage: 1,
limit: 50,
total: 0,
totalPages: 1
};
this.init();
}
init() {
this.bindEvents();
this.loadInitialData();
}
bindEvents() {
// Search
document.getElementById("searchBtn").addEventListener("click", () => this.search());
document.getElementById("searchText").addEventListener("keyup", (e) => {
if (e.key === "Enter") this.search();
});
// Bulk actions
document.getElementById("selectAll").addEventListener("change", (e) =>
this.toggleSelectAll(e.target.checked)
);
document.getElementById("bulkDeleteBtn").addEventListener("click", () => this.bulkDelete());
document.getElementById("bulkTagBtn").addEventListener("click", () => this.showBulkTagModal());
document.getElementById("bulkCategoryBtn").addEventListener("click", () => this.showBulkCategoryModal());
document.getElementById("exportBtn").addEventListener("click", () => this.exportPrompts());
document.getElementById("settingsBtn").addEventListener("click", () => this.showSettingsModal());
document.getElementById("statsBtn").addEventListener("click", () => this.showStats());
document.getElementById("diagnosticsBtn").addEventListener("click", () => this.showDiagnosticsModal());
document.getElementById("maintenanceBtn").addEventListener("click", () => this.showMaintenanceModal());
document.getElementById("backupBtn").addEventListener("click", () => this.backupDatabase());
document.getElementById("restoreBtn").addEventListener("click", () => this.showRestoreModal());
document.getElementById("scanBtn").addEventListener("click", () => this.showScanModal());
document.getElementById("autoTagBtn").addEventListener("click", () => this.showAutoTagModal());
document.getElementById("addPromptBtn").addEventListener("click", () => this.showAddPromptModal());
document.getElementById("logsBtn").addEventListener("click", () => this.showLogsModal());
document.getElementById("metadataBtn").addEventListener("click", () => this.openMetadataViewer());
document.getElementById("galleryBtn").addEventListener("click", () => this.openGallery());
// Pagination controls
document.getElementById("limitSelector").addEventListener("change", (e) => this.changeLimit(parseInt(e.target.value)));
document.getElementById("firstPageBtn").addEventListener("click", () => this.goToPage(1));
document.getElementById("prevPageBtn").addEventListener("click", () => this.goToPage(this.pagination.currentPage - 1));
document.getElementById("nextPageBtn").addEventListener("click", () => this.goToPage(this.pagination.currentPage + 1));
document.getElementById("lastPageBtn").addEventListener("click", () => this.goToPage(this.pagination.totalPages));
// Modals
this.bindModalEvents();
// Auto-search on filter changes
["searchCategory"].forEach((id) => {
document.getElementById(id).addEventListener("change", () => this.search());
});
// Sort dropdown change
document.getElementById("sortBy").addEventListener("change", () => this.renderPrompts());
}
bindModalEvents() {
// Settings modal
document.getElementById("saveSettings").addEventListener("click", () => this.saveSettings());
document.getElementById("cancelSettings").addEventListener("click", () => this.hideModal("settingsModal"));
document.getElementById("refreshMonitoringStatus").addEventListener("click", () => this.updateMonitoringStatus());
// Bulk tag modal
document.getElementById("confirmBulkTag").addEventListener("click", () => this.confirmBulkTag());
document.getElementById("cancelBulkTag").addEventListener("click", () => this.hideModal("bulkTagModal"));
// Individual tag modal
document.getElementById("confirmIndividualTag").addEventListener("click", () => this.confirmIndividualTag());
document.getElementById("cancelIndividualTag").addEventListener("click", () => this.hideModal("individualTagModal"));
// Bulk category modal
document.getElementById("confirmBulkCategory").addEventListener("click", () => this.confirmBulkCategory());
document.getElementById("cancelBulkCategory").addEventListener("click", () => this.hideModal("bulkCategoryModal"));
// Restore modal
document.getElementById("confirmRestore").addEventListener("click", () => this.confirmRestore());
document.getElementById("cancelRestore").addEventListener("click", () => this.hideModal("restoreModal"));
// Diagnostics modal
document.getElementById("runDiagnosticsBtn").addEventListener("click", () => this.runDiagnostics());
document.getElementById("testImageLinkBtn").addEventListener("click", () => this.testImageLink());
// Maintenance modal
document.getElementById("runMaintenanceBtn").addEventListener("click", () => this.runMaintenance());
document.getElementById("selectAllMaintenanceBtn").addEventListener("click", () => this.selectAllMaintenance());
document.getElementById("clearAllMaintenanceBtn").addEventListener("click", () => this.clearAllMaintenance());
// Scan modal
document.getElementById("startScan").addEventListener("click", () => this.startScan());
document.getElementById("cancelScan").addEventListener("click", () => this.hideModal("scanModal"));
document.getElementById("quickBackupBtn").addEventListener("click", () => this.quickBackup());
// Logs modal
document.getElementById("refreshLogsBtn").addEventListener("click", () => this.refreshLogs());
document.getElementById("downloadLogsBtn").addEventListener("click", () => this.downloadLogs());
document.getElementById("clearLogsBtn").addEventListener("click", () => this.clearLogs());
document.getElementById("updateLogConfigBtn").addEventListener("click", () => this.updateLogConfig());
// Auto Tag modals
document.getElementById("startAutoTagBtn").addEventListener("click", () => this.startAutoTag());
document.getElementById("startReviewBtn").addEventListener("click", () => this.startReview());
document.getElementById("skipReviewBtn").addEventListener("click", () => this.skipReviewImage());
document.getElementById("applyReviewBtn").addEventListener("click", () => this.applyReviewTags());
document.getElementById("cancelAutoTagBtn").addEventListener("click", () => this.cancelAutoTag());
document.getElementById("cancelDownloadBtn").addEventListener("click", () => this.cancelDownload());
// Re-tag confirmation modal buttons
document.getElementById("retagSkipBtn").addEventListener("click", () => this.handleRetagChoice('skip'));
document.getElementById("retagSkipAllBtn").addEventListener("click", () => this.handleRetagChoice('skipAll'));
document.getElementById("retagConfirmBtn").addEventListener("click", () => this.handleRetagChoice('retag'));
// Tags accordion toggle
document.getElementById("reviewTagsToggle").addEventListener("click", () => this.toggleReviewTags());
// Add Prompt modal
document.getElementById("saveNewPromptBtn").addEventListener("click", () => this.saveNewPrompt());
this.setupAddPromptModal();
// Close modals on backdrop click
document.querySelectorAll("[id$='Modal']").forEach((modal) => {
modal.addEventListener("click", (e) => {
if (e.target === modal) {
modal.classList.add("hidden");
modal.classList.remove("flex");
document.body.style.overflow = "";
}
});
});
}
async loadInitialData() {
try {
await Promise.all([
this.loadStatistics(),
this.loadCategories(),
this.loadTags(),
this.loadRecentPrompts(),
this.loadSettings(),
]);
} catch (error) {
this.showNotification("Failed to load initial data", "error");
console.error("Load error:", error);
}
}
async loadSettings() {
try {
const response = await fetch("/prompt_manager/settings");
if (response.ok) {
const data = await response.json();
if (data.success && data.settings) {
this.settings.resultTimeout = data.settings.result_timeout || 5;
this.settings.webuiDisplayMode = data.settings.webui_display_mode || 'popup';
this.settings.galleryRootPath = data.settings.gallery_root_path || '';
this.settings.monitoredDirectories = data.settings.monitored_directories || [];
}
}
} catch (error) {
console.error("Settings load error:", error);
}
}
async loadStatistics() {
try {
const response = await fetch("/prompt_manager/stats");
if (response.ok) {
const data = await response.json();
console.log("Stats response:", data); // Debug log
if (data.success) {
// Try different possible response structures
const stats = data.stats || data;
document.getElementById("totalPrompts").textContent = stats.total_prompts || data.total_prompts || 0;
document.getElementById("totalCategories").textContent = stats.total_categories || data.total_categories || 0;
document.getElementById("totalTags").textContent = stats.total_tags || data.total_tags || 0;
document.getElementById("avgRating").textContent =
(stats.avg_rating || data.avg_rating) ? (stats.avg_rating || data.avg_rating).toFixed(1) : "N/A";
} else {
console.error("Stats API returned success=false:", data);
}
} else {
console.error("Stats API HTTP error:", response.status, response.statusText);
}
} catch (error) {
console.error("Stats error:", error);
// Set some default values if API fails
document.getElementById("totalPrompts").textContent = this.prompts.length || 0;
document.getElementById("totalCategories").textContent = this.categories.length || 0;
document.getElementById("totalTags").textContent = this.tags.length || 0;
document.getElementById("avgRating").textContent = "N/A";
}
}
async loadCategories() {
try {
const response = await fetch("/prompt_manager/categories");
if (response.ok) {
const data = await response.json();
if (data.success) {
this.categories = data.categories;
this.populateCategoryDropdown();
}
}
} catch (error) {
console.error("Categories error:", error);
}
}
async loadTags() {
try {
const response = await fetch("/prompt_manager/tags");
if (response.ok) {
const data = await response.json();
if (data.success) {
this.tags = data.tags;
}
}
} catch (error) {
console.error("Tags error:", error);
}
}
populateCategoryDropdown() {
const select = document.getElementById("searchCategory");
select.innerHTML = '<option value="">All Categories</option>';
this.categories.forEach((category) => {
const option = document.createElement("option");
option.value = category;
option.textContent = category;
select.appendChild(option);
});
}
async loadRecentPrompts(page = 1) {
try {
this.pagination.currentPage = page;
const offset = (page - 1) * this.pagination.limit;
const response = await fetch(`/prompt_manager/recent?limit=${this.pagination.limit}&offset=${offset}&page=${page}`);
if (response.ok) {
const data = await response.json();
if (data.success) {
this.prompts = data.results;
this.pagination = {
...this.pagination,
total: data.pagination.total,
totalPages: data.pagination.total_pages,
currentPage: data.pagination.page
};
this.renderPrompts();
this.updatePaginationControls();
// Don't update stats from local data as it's only a subset of prompts
}
}
} catch (error) {
console.error("Recent prompts error:", error);
} finally {
document.getElementById("loadingState").classList.add("hidden");
document.getElementById("resultsList").classList.remove("hidden");
document.getElementById("paginationControls").classList.remove("hidden");
}
}
updateLocalStats() {
// Only update local stats if we haven't loaded proper statistics yet
// This prevents overwriting the correct total counts from the API
const currentTotal = document.getElementById("totalPrompts").textContent;
// Only update if the current total is still the default "-"
if (currentTotal === "-") {
// Show local counts as fallback only when API hasn't loaded yet
document.getElementById("totalPrompts").textContent = this.prompts.length;
const uniqueCategories = new Set(this.prompts.map(p => p.category).filter(Boolean));
document.getElementById("totalCategories").textContent = uniqueCategories.size;
const allTags = this.prompts.flatMap(p => Array.isArray(p.tags) ? p.tags : []);
const uniqueTags = new Set(allTags.filter(Boolean));
document.getElementById("totalTags").textContent = uniqueTags.size;
const ratings = this.prompts.map(p => p.rating).filter(r => r && r > 0);
if (ratings.length > 0) {
const avgRating = ratings.reduce((a, b) => a + b, 0) / ratings.length;
document.getElementById("avgRating").textContent = avgRating.toFixed(1);
}
}
}
async search() {
const searchText = document.getElementById("searchText").value;
const category = document.getElementById("searchCategory").value;
const tags = document.getElementById("searchTags").value;
document.getElementById("loadingState").classList.remove("hidden");
document.getElementById("resultsList").classList.add("hidden");
try {
const params = new URLSearchParams();
if (searchText) params.append("text", searchText);
if (category) params.append("category", category);
if (tags) params.append("tags", tags);
params.append("limit", "100");
const response = await fetch(`/prompt_manager/search?${params}`);
if (response.ok) {
const data = await response.json();
if (data.success) {
this.prompts = data.results;
this.renderPrompts();
document.getElementById("resultsTitle").textContent = "Search Results";
}
}
} catch (error) {
this.showNotification("Search failed", "error");
console.error("Search error:", error);
} finally {
document.getElementById("loadingState").classList.add("hidden");
document.getElementById("resultsList").classList.remove("hidden");
}
}
sortPrompts(prompts) {
const sortBy = document.getElementById("sortBy")?.value || "created_desc";
const [field, direction] = sortBy.split("_");
return [...prompts].sort((a, b) => {
let aVal, bVal;
switch (field) {
case "rating":
aVal = a.rating || 0;
bVal = b.rating || 0;
break;
case "created":
aVal = new Date(a.created_at);
bVal = new Date(b.created_at);
break;
case "text":
aVal = a.text.toLowerCase();
bVal = b.text.toLowerCase();
break;
default:
return 0;
}
if (direction === "asc") {
return aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
} else {
return aVal < bVal ? 1 : aVal > bVal ? -1 : 0;
}
});
}
renderPrompts() {
const container = document.getElementById("resultsList");
const count = document.getElementById("resultsCount");
count.textContent = `${this.prompts.length} prompts`;
if (this.prompts.length === 0) {
container.innerHTML = `
<div class="text-center py-12">
<div class="w-16 h-16 bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4">
<span class="text-2xl">📭</span>
</div>
<h3 class="text-lg font-medium text-gray-300 mb-2">No prompts found</h3>
<p class="text-gray-500">Try adjusting your search criteria or create some prompts in ComfyUI</p>
</div>
`;
return;
}
// Sort prompts before rendering
const sortedPrompts = this.sortPrompts(this.prompts);
container.innerHTML = sortedPrompts.map((prompt) => this.renderPromptItem(prompt)).join("");
this.selectedPrompts.clear();
this.updateBulkActionButtons();
// Add hover behavior to all star ratings
sortedPrompts.forEach(prompt => {
this.addStarHoverBehavior(prompt.id);
});
// Load film strips for each prompt (async, non-blocking)
this.loadAllFilmStrips(sortedPrompts);
}
async loadAllFilmStrips(prompts) {
// Load filmstrips in batches to avoid overwhelming the server
const batchSize = 5;
for (let i = 0; i < prompts.length; i += batchSize) {
const batch = prompts.slice(i, i + batchSize);
await Promise.all(batch.map(prompt => this.loadFilmStripForPrompt(prompt.id)));
}
}
async loadFilmStripForPrompt(promptId) {
const container = document.getElementById(`filmStrip-${promptId}`);
if (!container) return;
try {
const images = await this.loadFilmStripImages(promptId);
container.innerHTML = this.createFilmStrip(images, promptId);
} catch (error) {
console.error(`Error loading filmstrip for prompt ${promptId}:`, error);
container.innerHTML = `<div class="film-strip-empty">Failed to load images</div>`;
}
}
renderPromptItem(prompt) {
const tags = Array.isArray(prompt.tags) ? prompt.tags : [];
const category = prompt.category || "No category";
const rating = prompt.rating || 0;
const created = new Date(prompt.created_at).toLocaleDateString();
return `
<div class="bg-gray-700/50 rounded-xl border border-gray-600 hover:border-gray-500 transition-all duration-200" data-id="${prompt.id}">
<div class="p-6">
<div class="flex items-start space-x-4">
<input type="checkbox" class="prompt-checkbox w-5 h-5 text-blue-600 bg-gray-600 border-gray-500 rounded focus:ring-blue-500 mt-1" data-id="${prompt.id}">
<div class="flex-1 min-w-0">
<div class="bg-gray-800 rounded-lg p-4 mb-4 relative group">
<div class="prompt-text text-gray-100 leading-relaxed whitespace-pre-wrap" data-id="${prompt.id}">${this.escapeHtml(prompt.text)}</div>
<button class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white p-2 rounded-lg text-sm" onclick="window.admin.copyPromptToClipboard(${prompt.id})">
📋 Copy
</button>
</div>
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-400 mb-3">
<div class="flex items-center space-x-1">
<span>📁</span>
<span class="category-text" data-id="${prompt.id}">${category}</span>
</div>
<div class="flex items-center space-x-1">
<span>📅</span>
<span>${created}</span>
</div>
<div class="flex items-center space-x-1">
<div class="rating flex space-x-1" data-id="${prompt.id}" data-rating="${rating}">
${this.renderStars(rating, prompt.id)}
</div>
</div>
</div>
<div class="tags-accordion" data-prompt-id="${prompt.id}">
<div class="flex flex-wrap items-center gap-2">
${tags.slice(0, 10).map(tag => `
<span class="inline-flex items-center space-x-1 bg-blue-600/20 text-blue-300 px-3 py-1 rounded-full text-sm border border-blue-600/30">
<span>${this.escapeHtml(tag)}</span>
<button class="text-blue-300 hover:text-blue-100 ml-1" onclick="window.admin.removeTag(${prompt.id}, '${this.escapeHtml(tag)}')">&times;</button>
</span>
`).join("")}
<button class="inline-flex items-center space-x-1 bg-gray-600 hover:bg-gray-500 text-gray-300 px-3 py-1 rounded-full text-sm transition-colors" onclick="window.admin.addTag(${prompt.id})">
<span>+</span>
<span>Add Tags</span>
</button>
</div>
${tags.length > 10 ? `
<div class="tags-hidden hidden mt-2">
<div class="flex flex-wrap items-center gap-2 max-h-[180px] overflow-y-auto custom-scrollbar p-2 bg-gray-800/50 rounded-lg">
${tags.slice(10).map(tag => `
<span class="inline-flex items-center space-x-1 bg-blue-600/20 text-blue-300 px-3 py-1 rounded-full text-sm border border-blue-600/30">
<span>${this.escapeHtml(tag)}</span>
<button class="text-blue-300 hover:text-blue-100 ml-1" onclick="window.admin.removeTag(${prompt.id}, '${this.escapeHtml(tag)}')">&times;</button>
</span>
`).join("")}
</div>
</div>
<button class="tags-toggle-btn mt-2 text-sm text-blue-400 hover:text-blue-300 transition-colors flex items-center gap-1" onclick="window.admin.toggleMainTags(${prompt.id})">
<span class="toggle-icon">▼</span>
<span class="toggle-text">Show ${tags.length - 10} more tags</span>
</button>
` : ''}
</div>
<!-- Film Strip -->
<div class="film-strip-container mt-3 pt-3 border-t border-gray-600/50" id="filmStrip-${prompt.id}">
<div class="film-strip-empty">Loading images...</div>
</div>
</div>
<div class="flex flex-col space-y-2">
<button class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium rounded-lg transition-colors" onclick="window.admin.viewGallery(${prompt.id})">
🖼️ Gallery
</button>
<button class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" onclick="window.admin.editPrompt(${prompt.id})">
✏️ Edit
</button>
<button class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors" onclick="window.admin.deletePrompt(${prompt.id})">
🗑️ Delete
</button>
</div>
</div>
</div>
</div>
`;
}
renderStars(rating, promptId) {
return Array.from({ length: 5 }, (_, i) => {
const starNum = i + 1;
const isActive = starNum <= rating;
const starIcon = isActive ? '⭐' : '☆'; // filled star vs outline star
const color = isActive ? '#fbbf24' : '#9ca3af'; // yellow-400 : gray-400
return `<button class="star-btn" data-star="${starNum}" data-prompt-id="${promptId}" style="font-size: 1.125rem; color: ${color}; background: none; border: none; cursor: pointer; transition: all 0.2s; width: 1.125rem; height: 1.125rem; display: inline-flex; align-items: center; justify-content: center; padding: 0; line-height: 1;" onclick="window.admin.setRating(${promptId}, ${starNum})">${starIcon}</button>`;
}).join("");
}
addStarHoverBehavior(promptId) {
const starButtons = document.querySelectorAll(`[data-prompt-id="${promptId}"].star-btn`);
const ratingElement = document.querySelector(`[data-id="${promptId}"][data-rating]`);
const currentRating = parseInt(ratingElement?.dataset.rating || 0);
starButtons.forEach((starBtn, index) => {
const starNum = index + 1;
starBtn.addEventListener('mouseenter', () => {
// Light up all stars up to this one on hover
starButtons.forEach((btn, i) => {
if (i < starNum) {
btn.textContent = '⭐';
btn.style.color = '#fcd34d'; // bright yellow on hover
} else {
btn.textContent = '☆';
btn.style.color = '#9ca3af'; // gray
}
});
});
starBtn.addEventListener('mouseleave', () => {
// Reset to current rating
starButtons.forEach((btn, i) => {
if (i < currentRating) {
btn.textContent = '⭐';
btn.style.color = '#fbbf24'; // yellow
} else {
btn.textContent = '☆';
btn.style.color = '#9ca3af'; // gray
}
});
});
});
}
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
showNotification(message, type = "info") {
const notification = document.createElement("div");
// Check if ViewerJS is open and use higher z-index
const viewerContainer = document.querySelector('.viewer-container');
const isViewerOpen = viewerContainer && viewerContainer.style.display !== 'none';
const zIndex = isViewerOpen ? 'z-[50000]' : 'z-50';
notification.className = `fixed top-4 right-4 px-6 py-4 rounded-lg shadow-lg ${zIndex} transition-all duration-300 transform translate-x-full`;
const colors = {
success: "bg-green-600 text-white",
error: "bg-red-600 text-white",
warning: "bg-yellow-600 text-white",
info: "bg-blue-600 text-white"
};
notification.className += ` ${colors[type] || colors.info}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => notification.classList.remove("translate-x-full"), 100);
setTimeout(() => {
notification.classList.add("translate-x-full");
setTimeout(() => {
if (notification.parentNode) {
document.body.removeChild(notification);
}
}, 300);
}, 3000);
}
showModal(modalId) {
document.getElementById(modalId).classList.remove("hidden");
document.getElementById(modalId).classList.add("flex");
document.body.style.overflow = "hidden";
}
hideModal(modalId) {
document.getElementById(modalId).classList.add("hidden");
document.getElementById(modalId).classList.remove("flex");
document.body.style.overflow = "";
}
showSettingsModal() {
document.getElementById("resultTimeout").value = this.settings.resultTimeout;
document.getElementById("webuiDisplayMode").value = this.settings.webuiDisplayMode;
document.getElementById("galleryRootPath").value = this.settings.galleryRootPath || '';
this.updateMonitoringStatus();
this.showModal("settingsModal");
}
async updateMonitoringStatus() {
const statusEl = document.getElementById("monitoringStatus");
try {
const response = await fetch("/prompt_manager/settings");
if (response.ok) {
const data = await response.json();
const dirs = data.settings?.monitored_directories || [];
if (dirs.length > 0) {
statusEl.innerHTML = dirs.map(d => `<div class="truncate" title="${d}">✓ ${d}</div>`).join('');
} else {
statusEl.textContent = 'No directories being monitored (auto-detect on restart)';
}
}
} catch (error) {
statusEl.textContent = 'Unable to fetch status';
}
}
async saveSettings() {
const timeout = parseInt(document.getElementById("resultTimeout").value);
const displayMode = document.getElementById("webuiDisplayMode").value;
const galleryPath = document.getElementById("galleryRootPath").value.trim();
this.settings.resultTimeout = timeout;
this.settings.webuiDisplayMode = displayMode;
this.settings.galleryRootPath = galleryPath;
try {
const response = await fetch("/prompt_manager/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
result_timeout: timeout,
webui_display_mode: displayMode,
gallery_root_path: galleryPath
}),
});
if (response.ok) {
const data = await response.json();
if (data.restart_required) {
this.showNotification("Settings saved. Restart ComfyUI for gallery path changes to take effect.", "warning");
} else {
this.showNotification("Settings saved successfully", "success");
}
this.hideModal("settingsModal");
} else {
throw new Error("Failed to save settings");
}
} catch (error) {
this.showNotification("Failed to save settings", "error");
}
}
toggleSelectAll(checked) {
document.querySelectorAll(".prompt-checkbox").forEach((checkbox) => {
checkbox.checked = checked;
if (checked) {
this.selectedPrompts.add(parseInt(checkbox.dataset.id));
} else {
this.selectedPrompts.clear();
}
});
this.updateBulkActionButtons();
}
updateBulkActionButtons() {
const hasSelected = this.selectedPrompts.size > 0;
document.getElementById("bulkDeleteBtn").disabled = !hasSelected;
document.getElementById("bulkTagBtn").disabled = !hasSelected;
document.getElementById("bulkCategoryBtn").disabled = !hasSelected;
}
async setRating(promptId, rating) {
console.log(`Setting rating for prompt ${promptId} to ${rating}`);
// Update UI immediately for better UX
const ratingElement = document.querySelector(`[data-id="${promptId}"][data-rating]`);
if (ratingElement) {
ratingElement.dataset.rating = rating;
ratingElement.innerHTML = this.renderStars(rating, promptId);
// Add hover behavior to new stars
this.addStarHoverBehavior(promptId);
}
try {
const response = await fetch(`/prompt_manager/prompts/${promptId}/rating`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rating }),
});
if (response.ok) {
this.showNotification("Rating updated", "success");
// Update the prompt data in memory
const promptIndex = this.prompts.findIndex(p => p.id === promptId);
if (promptIndex !== -1) {
this.prompts[promptIndex].rating = rating;
}
// Refresh stats
setTimeout(() => {
this.loadStatistics();
}, 500);
} else {
// Revert UI change on API failure
const originalPrompt = this.prompts.find(p => p.id === promptId);
const originalRating = originalPrompt ? originalPrompt.rating : 0;
if (ratingElement) {
ratingElement.dataset.rating = originalRating;
ratingElement.innerHTML = this.renderStars(originalRating, promptId);
}
this.showNotification("Failed to update rating", "error");
}
} catch (error) {
console.error("Rating update error:", error);
// Revert UI change on error
const originalPrompt = this.prompts.find(p => p.id === promptId);
const originalRating = originalPrompt ? originalPrompt.rating : 0;
if (ratingElement) {
ratingElement.dataset.rating = originalRating;
ratingElement.innerHTML = this.renderStars(originalRating, promptId);
}
this.showNotification("Failed to update rating", "error");
}
}
addTag(promptId) {
// Store the prompt ID for later use
this.currentTagPromptId = promptId;
// Clear the input and show the modal
document.getElementById("individualTagInput").value = "";
this.showModal("individualTagModal");
}
async confirmIndividualTag() {
const tags = document.getElementById("individualTagInput").value.split(",")
.map((tag) => tag.trim()).filter((tag) => tag);
if (tags.length === 0) return;
try {
const response = await fetch("/prompt_manager/prompts/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt_id: this.currentTagPromptId,
tags: tags,
}),
});
if (response.ok) {
this.showNotification(`Tags added to prompt`, "success");
this.hideModal("individualTagModal");
// Refresh both stats and results
await this.loadStatistics();
this.search();
}
} catch (error) {
this.showNotification("Failed to add tags", "error");
}
}
async removeTag(promptId, tag) {
try {
const response = await fetch(`/prompt_manager/prompts/${promptId}/tags`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag }),
});
if (response.ok) {
this.showNotification("Tag removed", "success");
// Refresh both stats and results
await this.loadStatistics();
this.search();
}
} catch (error) {
this.showNotification("Failed to remove tag", "error");
}
}
async editPrompt(promptId) {
const promptElement = document.querySelector(`[data-id="${promptId}"].prompt-text`);
if (!promptElement) return;
const originalText = promptElement.textContent;
promptElement.contentEditable = true;
promptElement.focus();
promptElement.classList.add("bg-gray-700", "border", "border-blue-500", "rounded-lg", "p-3");
const saveEdit = async () => {
const newText = promptElement.textContent.trim();
if (newText !== originalText && newText) {
try {
const response = await fetch(`/prompt_manager/prompts/${promptId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: newText }),
});
if (response.ok) {
this.showNotification("Prompt updated", "success");
// Refresh the page to show updated content
setTimeout(() => {
this.loadStatistics();
setTimeout(() => window.location.reload(), 500);
}, 1000);
} else {
throw new Error("Update failed");
}
} catch (error) {
this.showNotification("Failed to update prompt", "error");
promptElement.textContent = originalText;
}
}
promptElement.contentEditable = false;
promptElement.classList.remove("bg-gray-700", "border", "border-blue-500", "rounded-lg", "p-3");
};
promptElement.addEventListener("blur", saveEdit, { once: true });
promptElement.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
saveEdit();
}
if (e.key === "Escape") {
promptElement.textContent = originalText;
promptElement.contentEditable = false;
promptElement.classList.remove("bg-gray-700", "border", "border-blue-500", "rounded-lg", "p-3");
}
});
}
async deletePrompt(promptId) {
if (!confirm("Are you sure you want to delete this prompt?")) return;
try {
const response = await fetch(`/prompt_manager/delete/${promptId}`, {
method: "DELETE",
});
if (response.ok) {
this.showNotification("Prompt deleted", "success");
// Refresh stats and reload page
setTimeout(() => {
this.loadStatistics();
setTimeout(() => window.location.reload(), 500);
}, 1000);
}
} catch (error) {
this.showNotification("Failed to delete prompt", "error");
}
}
showBulkTagModal() {
this.showModal("bulkTagModal");
}
showBulkCategoryModal() {
this.showModal("bulkCategoryModal");
}
async confirmBulkTag() {
const tags = document.getElementById("bulkTagInput").value.split(",")
.map((tag) => tag.trim()).filter((tag) => tag);
if (tags.length === 0) return;
try {
const response = await fetch("/prompt_manager/bulk/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt_ids: Array.from(this.selectedPrompts),
tags,
}),
});
if (response.ok) {
this.showNotification(`Tags added to ${this.selectedPrompts.size} prompts`, "success");
this.hideModal("bulkTagModal");
// Refresh both stats and results
await this.loadStatistics();
this.search();
}
} catch (error) {
this.showNotification("Failed to add tags", "error");
}
}
async confirmBulkCategory() {
const category = document.getElementById("bulkCategoryInput").value.trim();
if (!category) return;
try {
const response = await fetch("/prompt_manager/bulk/category", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt_ids: Array.from(this.selectedPrompts),
category,
}),
});
if (response.ok) {
this.showNotification(`Category set for ${this.selectedPrompts.size} prompts`, "success");
this.hideModal("bulkCategoryModal");
// Refresh both stats and results
await this.loadStatistics();
this.search();
}
} catch (error) {
this.showNotification("Failed to set category", "error");
}
}
async bulkDelete() {
if (!confirm(`Are you sure you want to delete ${this.selectedPrompts.size} prompts?`)) return;
try {
const response = await fetch("/prompt_manager/bulk/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt_ids: Array.from(this.selectedPrompts),
}),
});
if (response.ok) {
this.showNotification(`${this.selectedPrompts.size} prompts deleted`, "success");
// Refresh stats and reload page
setTimeout(() => {
this.loadStatistics();
setTimeout(() => window.location.reload(), 500);
}, 1000);
}
} catch (error) {
this.showNotification("Failed to delete prompts", "error");
}
}
async exportPrompts() {
try {
const response = await fetch("/prompt_manager/export");
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `prompts-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
this.showNotification("Prompts exported", "success");
}
} catch (error) {
this.showNotification("Failed to export prompts", "error");
}
}
async showStats() {
await this.loadStatistics();
this.showNotification("📊 Stats refreshed successfully!", "success");
}
// Gallery functionality
async viewGallery(promptId) {
this.showModal("galleryModal");
// Show loading state
document.getElementById("galleryLoading").classList.remove("hidden");
document.getElementById("galleryContent").classList.add("hidden");
document.getElementById("galleryEmpty").classList.add("hidden");
try {
const response = await fetch(`/prompt_manager/prompts/${promptId}/images`);
if (response.ok) {
const data = await response.json();
if (data.success) {
this.renderGallery(data.images, promptId);
} else {
throw new Error(data.error || 'Failed to load images');
}
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
console.error('Gallery load error:', error);
this.showNotification('Failed to load gallery', 'error');
this.closeGallery();
} finally {
document.getElementById("galleryLoading").classList.add("hidden");
}
}
renderGallery(images, promptId) {
const content = document.getElementById("galleryContent");
const empty = document.getElementById("galleryEmpty");
// Update title
const prompt = this.prompts.find(p => p.id === promptId);
const promptText = prompt ? prompt.text.substring(0, 50) + (prompt.text.length > 50 ? '...' : '') : 'Unknown Prompt';
document.getElementById("galleryTitle").textContent = `Gallery: ${promptText}`;
if (!images || images.length === 0) {
content.classList.add("hidden");
empty.classList.remove("hidden");
return;
}
content.classList.remove("hidden");
empty.classList.add("hidden");
content.innerHTML = images.map((image, index) => `
<div class="group cursor-pointer bg-gray-700 rounded-xl overflow-hidden border border-gray-600 hover:border-gray-500 transition-all duration-200">
<div class="aspect-square bg-gray-800 overflow-hidden relative">
<img src="/prompt_manager/images/${image.id}/file"
alt="Generated image ${index + 1}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
data-original="/prompt_manager/images/${image.id}/file"
data-caption="Generated: ${new Date(image.generation_time).toLocaleDateString()} ${new Date(image.generation_time).toLocaleTimeString()} | ${image.width && image.height ? `${image.width}×${image.height}` : 'Unknown size'}${image.file_size ? ` | ${this.formatFileSize(image.file_size)}` : ''}"
onerror="this.parentElement.innerHTML='<div class=\\'flex items-center justify-center h-full text-gray-400\\'>⚠️ Image not found</div>'">
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-opacity duration-300 flex items-center justify-center pointer-events-none">
<svg class="w-8 h-8 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
</div>
<div class="p-3">
<div class="text-xs text-gray-400 mb-1">
${new Date(image.generation_time).toLocaleDateString()} ${new Date(image.generation_time).toLocaleTimeString()}
</div>
<div class="text-xs text-gray-500">
${image.width && image.height ? `${image.width}×${image.height}` : 'Unknown size'}
${image.file_size ? ` • ${this.formatFileSize(image.file_size)}` : ''}
</div>
</div>
</div>
`).join('');
// Store images for navigation
this.currentGalleryImages = images;
// Initialize ViewerJS for the gallery after DOM is updated
setTimeout(() => {
if (this.galleryViewer) {
this.galleryViewer.destroy();
}
this.galleryViewer = new Viewer(content, {
toolbar: {
zoomIn: 1,
zoomOut: 1,
oneToOne: 1,
reset: 1,
prev: 1,
play: {
show: 1,
size: 'large',
},
next: 1,
rotateLeft: 1,
rotateRight: 1,
flipHorizontal: 1,
flipVertical: 1,
},
navbar: true,
title: true,
transition: true,
keyboard: true,
backdrop: 'static',
loading: true,
loop: true,
tooltip: true,
zoomRatio: 0.1,
minZoomRatio: 0.1,
maxZoomRatio: 4,
zoomOnTouch: true,
zoomOnWheel: true,
slideOnTouch: true,
toggleOnDblclick: false,
className: 'viewer-dark-theme',
shown: () => {
this.addMetadataSidebar();
},
viewed: (event) => {
this.loadMetadataForImage(event.detail.image);
}
});
}, 100);
}
closeGallery() {
this.hideModal("galleryModal");
}
formatFileSize(bytes) {
if (!bytes) return 'Unknown';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
// Diagnostics functionality
showDiagnosticsModal() {
this.showModal("diagnosticsModal");
}
closeDiagnostics() {
this.hideModal("diagnosticsModal");
}
// Maintenance functionality
showMaintenanceModal() {
this.showModal("maintenanceModal");
}
closeMaintenance() {
this.hideModal("maintenanceModal");
}
selectAllMaintenance() {
document.querySelectorAll('.maintenance-option').forEach(checkbox => {
checkbox.checked = true;
});
}
clearAllMaintenance() {
document.querySelectorAll('.maintenance-option').forEach(checkbox => {
checkbox.checked = false;
});
}
async runMaintenance() {
const resultsContainer = document.getElementById("maintenanceResults");
const runButton = document.getElementById("runMaintenanceBtn");
// Get selected operations
const selectedOps = Array.from(document.querySelectorAll('.maintenance-option:checked'))
.map(checkbox => checkbox.id);
if (selectedOps.length === 0) {
this.showNotification('Please select at least one maintenance operation', 'warning');
return;
}
// Show loading state
runButton.disabled = true;
runButton.innerHTML = '🔄 Running...';
resultsContainer.innerHTML = `
<div class="text-center py-8">
<div class="w-12 h-12 border-4 border-teal-500/30 border-t-teal-500 rounded-full animate-spin mx-auto mb-4"></div>
<p class="text-gray-400">Running maintenance operations...</p>
<p class="text-sm text-gray-500 mt-2">Operations: ${selectedOps.map(op => op.replace(/_/g, ' ')).join(', ')}</p>
</div>
`;
try {
const response = await fetch('/prompt_manager/maintenance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operations: selectedOps })
});
if (response.ok) {
const data = await response.json();
if (data.success) {
this.renderMaintenanceResults(data);
// Show overall success notification
if (data.all_successful) {
this.showNotification('✅ All maintenance operations completed successfully!', 'success');
} else {
this.showNotification('⚠️ Some maintenance operations had issues', 'warning');
}
// Refresh stats after maintenance
setTimeout(() => this.loadStatistics(), 1000);
} else {
throw new Error(data.error || 'Maintenance failed');
}
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
console.error('Maintenance error:', error);
resultsContainer.innerHTML = `
<div class="bg-red-900/20 border border-red-600 rounded-lg p-4">
<h4 class="text-red-400 font-medium mb-2">❌ Maintenance Failed</h4>
<p class="text-gray-300">${error.message}</p>
</div>
`;
this.showNotification('❌ Maintenance failed', 'error');
} finally {
runButton.disabled = false;
runButton.innerHTML = '🔧 Run Maintenance';
}
}
renderMaintenanceResults(data) {
const resultsContainer = document.getElementById("maintenanceResults");
let html = '<div class="space-y-4">';
// Summary
html += '<div class="bg-gray-700 rounded-lg p-4">';
html += '<h4 class="text-white font-medium mb-3">📋 Maintenance Summary</h4>';
html += '<div class="grid grid-cols-1 md:grid-cols-2 gap-3">';
html += `
<div class="flex justify-between items-center p-2 bg-gray-600 rounded">
<span class="text-gray-300">Operations Completed</span>
<span class="text-green-400 font-mono text-sm">${data.operations_completed}</span>
</div>
`;
html += `
<div class="flex justify-between items-center p-2 bg-gray-600 rounded">
<span class="text-gray-300">Overall Success</span>
<span class="${data.all_successful ? 'text-green-400' : 'text-yellow-400'} font-mono text-sm">
${data.all_successful ? '✅ ALL PASSED' : '⚠️ SOME ISSUES'}
</span>
</div>
`;
html += '</div></div>';
// Detailed results
for (const [operation, result] of Object.entries(data.results)) {
const bgColor = result.success ? 'bg-green-900/20 border-green-600' : 'bg-red-900/20 border-red-600';
const statusIcon = result.success ? '✅' : '❌';
const statusText = result.success ? 'SUCCESS' : 'FAILED';
html += `<div class="${bgColor} border rounded-lg p-4">`;
html += `<h4 class="text-white font-medium mb-2 flex items-center space-x-2">`;
html += `<span>${statusIcon}</span>`;
html += `<span class="capitalize">${operation.replace(/_/g, ' ')}</span>`;
html += `<span class="text-sm font-mono ${result.success ? 'text-green-400' : 'text-red-400'}">${statusText}</span>`;
html += `</h4>`;
if (result.message) {
html += `<p class="text-gray-300 mb-2">${result.message}</p>`;
}
// Show specific details
if (result.removed_count !== undefined) {
html += `<div class="text-sm text-gray-400">Items removed: ${result.removed_count}</div>`;
}
if (result.duplicate_hashes !== undefined) {
html += `<div class="text-sm text-gray-400">Duplicate hash groups found: ${result.duplicate_hashes}</div>`;
}
if (result.issues_found !== undefined) {
html += `<div class="text-sm text-gray-400">Issues found: ${result.issues_found}</div>`;
if (result.issues && result.issues.length > 0) {
html += `<ul class="ml-4 list-disc text-xs text-gray-500 mt-1">`;
result.issues.forEach(issue => {
html += `<li>${issue}</li>`;
});
html += `</ul>`;
}
}
if (result.info) {
html += `<div class="text-sm text-gray-400 mt-2">`;
html += `<p>Total prompts: ${result.info.total_prompts || 'N/A'}</p>`;
html += `<p>Database size: ${result.info.database_size_bytes ? this.formatFileSize(result.info.database_size_bytes) : 'N/A'}</p>`;
html += `</div>`;
}
if (result.error) {
html += `<div class="text-sm text-red-300 mt-2 font-mono bg-red-900/20 p-2 rounded">`;
html += `Error: ${result.error}`;
html += `</div>`;
}
html += '</div>';
}
html += '</div>';
resultsContainer.innerHTML = html;
}
async runDiagnostics() {
const content = document.getElementById("diagnosticsContent");
content.innerHTML = `
<div class="text-center py-8">
<div class="w-12 h-12 border-4 border-orange-500/30 border-t-orange-500 rounded-full animate-spin mx-auto mb-4"></div>
<p class="text-gray-400">Running diagnostics...</p>
</div>
`;
try {
const response = await fetch('/prompt_manager/diagnostics');
if (response.ok) {
const data = await response.json();
if (data.success) {
this.renderDiagnostics(data.diagnostics);
} else {
throw new Error(data.error || 'Diagnostics failed');
}
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
console.error('Diagnostics error:', error);
content.innerHTML = `
<div class="bg-red-900/20 border border-red-600 rounded-lg p-4">
<h4 class="text-red-400 font-medium mb-2">❌ Diagnostics Failed</h4>
<p class="text-gray-300">${error.message}</p>
</div>
`;
}
}
renderDiagnostics(diagnostics) {
const content = document.getElementById("diagnosticsContent");
let html = '<div class="space-y-4">';
// Summary
html += '<div class="bg-gray-700 rounded-lg p-4">';
html += '<h4 class="text-white font-medium mb-3">📋 Diagnostic Summary</h4>';
html += '<div class="grid grid-cols-1 md:grid-cols-2 gap-3">';
for (const [category, result] of Object.entries(diagnostics)) {
const status = result.status === 'ok' ? '✅ PASS' : (result.status === 'warning' ? '⚠️ WARNING' : '❌ FAIL');
const statusColor = result.status === 'ok' ? 'text-green-400' : (result.status === 'warning' ? 'text-yellow-400' : 'text-red-400');
html += `
<div class="flex justify-between items-center p-2 bg-gray-600 rounded">
<span class="text-gray-300 capitalize">${category}</span>
<span class="${statusColor} font-mono text-sm">${status}</span>
</div>
`;
}
html += '</div></div>';
// Detailed results
for (const [category, result] of Object.entries(diagnostics)) {
const bgColor = result.status === 'ok' ? 'bg-green-900/20 border-green-600' :
(result.status === 'warning' ? 'bg-yellow-900/20 border-yellow-600' : 'bg-red-900/20 border-red-600');
html += `<div class="${bgColor} border rounded-lg p-4">`;
html += `<h4 class="text-white font-medium mb-2 capitalize">${category}</h4>`;
if (result.message) {
html += `<p class="text-gray-300 mb-2">${result.message}</p>`;
}
// Show specific details based on category
if (category === 'database' && result.status === 'ok') {
html += `<div class="text-sm text-gray-400">`;
html += `<p>Prompts: ${result.prompt_count || 0}</p>`;
html += `<p>Images table: ${result.has_images_table ? 'Yes' : 'No'}</p>`;
html += `</div>`;
}
if (category === 'images_table' && result.status === 'ok') {
html += `<div class="text-sm text-gray-400">`;
html += `<p>Images: ${result.image_count || 0}</p>`;
if (result.recent_images && result.recent_images.length > 0) {
html += `<p>Recent images:</p>`;
html += `<ul class="ml-4 list-disc">`;
result.recent_images.slice(0, 3).forEach(img => {
html += `<li>${img.filename} → Prompt ${img.prompt_id}</li>`;
});
html += `</ul>`;
}
html += `</div>`;
}
if (category === 'comfyui_output' && result.output_dirs) {
html += `<div class="text-sm text-gray-400">`;
html += `<p>Output directories found:</p>`;
html += `<ul class="ml-4 list-disc">`;
result.output_dirs.forEach(dir => {
html += `<li>${dir}</li>`;
});
html += `</ul>`;
html += `</div>`;
}
if (category === 'dependencies' && result.dependencies) {
html += `<div class="text-sm text-gray-400">`;
for (const [dep, available] of Object.entries(result.dependencies)) {
const status = available ? '✅' : '❌';
html += `<p>${status} ${dep}</p>`;
}
html += `</div>`;
}
html += '</div>';
}
html += '</div>';
content.innerHTML = html;
}
async testImageLink() {
if (this.prompts.length === 0) {
this.showNotification('No prompts available for testing', 'warning');
return;
}
// Use the first prompt for testing
const testPrompt = this.prompts[0];
try {
const response = await fetch('/prompt_manager/diagnostics/test-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt_id: testPrompt.id,
image_path: '/test/fake/image.png'
})
});
if (response.ok) {
const data = await response.json();
if (data.success) {
this.showNotification('✅ Test image link created successfully!', 'success');
// Refresh the gallery to show the test image
setTimeout(() => this.search(), 1000);
} else {
throw new Error(data.result?.message || 'Test failed');
}
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
console.error('Test link error:', error);
this.showNotification(`❌ Test failed: ${error.message}`, 'error');
}
}
// Database backup and restore functionality
async backupDatabase() {
try {
const response = await fetch('/prompt_manager/backup');
if (response.ok) {
// Get the filename from the Content-Disposition header
const contentDisposition = response.headers.get('Content-Disposition');
let filename = 'prompts_backup.db';
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="(.+)"/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
// Create blob and download
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
this.showNotification('💾 Database backup downloaded successfully!', 'success');
} else {
const errorData = await response.json();
throw new Error(errorData.error || 'Backup failed');
}
} catch (error) {
console.error('Backup error:', error);
this.showNotification(`❌ Backup failed: ${error.message}`, 'error');
}
}
showRestoreModal() {
// Clear previous file selection
document.getElementById('restoreFileInput').value = '';
this.showModal('restoreModal');
}
async confirmRestore() {
const fileInput = document.getElementById('restoreFileInput');
const file = fileInput.files[0];
if (!file) {
this.showNotification('Please select a database file to restore', 'warning');
return;
}
if (!file.name.endsWith('.db')) {
this.showNotification('Please select a valid .db file', 'warning');
return;
}
// Show confirmation
const confirmed = confirm(
`Are you sure you want to restore from "${file.name}"?\n\n` +
'This will replace your current database. A backup will be created automatically.\n\n' +
'This action cannot be undone.'
);
if (!confirmed) {
return;
}
try {
// Disable the restore button and show loading
const confirmBtn = document.getElementById('confirmRestore');
confirmBtn.disabled = true;
confirmBtn.innerHTML = '🔄 Restoring...';
// Create FormData for file upload
const formData = new FormData();
formData.append('database_file', file);
const response = await fetch('/prompt_manager/restore', {
method: 'POST',
body: formData
});
const data = await response.json();
if (response.ok && data.success) {
this.showNotification(
`✅ Database restored successfully! Found ${data.prompt_count} prompts.`,
'success'
);
this.hideModal('restoreModal');
// Refresh the interface to show new data
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
throw new Error(data.error || 'Restore failed');
}
} catch (error) {
console.error('Restore error:', error);
this.showNotification(`❌ Restore failed: ${error.message}`, 'error');
} finally {
// Re-enable the restore button
const confirmBtn = document.getElementById('confirmRestore');
confirmBtn.disabled = false;
confirmBtn.innerHTML = 'Restore Database';
}
}
// Copy prompt to clipboard functionality
async copyPromptToClipboard(promptId) {
// Find the prompt text from the prompts array
const prompt = this.prompts.find(p => p.id === promptId);
if (!prompt) {
this.showNotification('❌ Prompt not found', 'error');
return;
}
const promptText = prompt.text;
try {
// Use the modern Clipboard API if available
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(promptText);
this.showNotification('📋 Prompt copied to clipboard!', 'success');
} else {
// Fallback for older browsers or non-secure contexts
const textArea = document.createElement('textarea');
textArea.value = promptText;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
if (document.execCommand('copy')) {
this.showNotification('📋 Prompt copied to clipboard!', 'success');
} else {
throw new Error('Copy command failed');
}
document.body.removeChild(textArea);
}
} catch (error) {
console.error('Copy to clipboard failed:', error);
this.showNotification('❌ Failed to copy prompt to clipboard', 'error');
// Show a fallback modal with the text for manual copying
this.showCopyFallbackModal(promptText);
}
}
showCopyFallbackModal(text) {
// Create a temporary modal for manual copy
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
modal.innerHTML = `
<div class="bg-gray-800 rounded-xl p-6 max-w-2xl w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-4">📋 Copy Prompt Text</h3>
<p class="text-gray-400 mb-4">Please manually copy the text below:</p>
<textarea readonly class="w-full h-32 px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 resize-none" style="font-family: monospace;">${text}</textarea>
<div class="flex justify-end mt-4">
<button class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors" onclick="this.closest('[class*=fixed]').remove()">
Close
</button>
</div>
</div>
`;
document.body.appendChild(modal);
document.body.style.overflow = "hidden";
// Auto-select the text in the textarea
const textarea = modal.querySelector('textarea');
textarea.focus();
textarea.select();
// Close modal when clicking outside
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
document.body.style.overflow = "";
}
});
// Also handle the close button
modal.querySelector('button').addEventListener('click', () => {
document.body.style.overflow = "";
});
}
// Scan functionality
showScanModal() {
// Reset progress display
document.getElementById("scanProgress").classList.add("hidden");
document.getElementById("startScan").disabled = false;
document.getElementById("startScan").textContent = "Start Scan";
this.showModal("scanModal");
}
async quickBackup() {
try {
const response = await fetch("/prompt_manager/backup");
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `prompts-backup-${new Date().toISOString().split("T")[0]}.db`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
this.showNotification("Database backup created successfully!", "success");
} else {
throw new Error("Backup failed");
}
} catch (error) {
this.showNotification("Failed to create backup", "error");
}
}
async startScan() {
// Show progress section
document.getElementById("scanProgress").classList.remove("hidden");
document.getElementById("startScan").disabled = true;
document.getElementById("startScan").textContent = "Scanning...";
// Reset progress
this.updateScanProgress(0, "Initializing scan...", 0, 0);
try {
const response = await fetch("/prompt_manager/scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Handle streaming response
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.trim().startsWith('data: ')) {
try {
const data = JSON.parse(line.substring(6));
if (data.type === 'progress') {
this.updateScanProgress(
data.progress,
data.status,
data.processed,
data.found
);
} else if (data.type === 'complete') {
this.completeScan(data.processed, data.found, data.added, data.linked);
return;
} else if (data.type === 'error') {
throw new Error(data.message);
}
} catch (e) {
console.log('Non-JSON line:', line);
}
}
}
}
} catch (error) {
this.showNotification(`Scan failed: ${error.message}`, "error");
document.getElementById("startScan").disabled = false;
document.getElementById("startScan").textContent = "Start Scan";
}
}
updateScanProgress(progress, status, processed, found) {
document.getElementById("scanProgressBar").style.width = `${progress}%`;
document.getElementById("scanStatusText").textContent = status;
document.getElementById("scanCount").textContent = `${processed} files processed`;
document.getElementById("scanFound").textContent = `${found} prompts found`;
}
completeScan(processed, found, added, linked = 0) {
this.updateScanProgress(100, "Scan completed!", processed, found);
document.getElementById("startScan").disabled = false;
document.getElementById("startScan").textContent = "Start New Scan";
// Show detailed notification with all counts
const linkedText = linked > 0 ? `, linked ${linked} images to existing prompts` : '';
this.showNotification(
`Scan completed! Processed ${processed} files, found ${found} prompts, added ${added} new prompts to database${linkedText}.`,
"success"
);
// Auto-close modal after a short delay to let user see the completion message
setTimeout(() => {
this.hideModal("scanModal");
// Refresh the statistics immediately
this.loadStatistics();
// Also reload the page after a short delay to ensure everything is fully updated
setTimeout(() => {
window.location.reload();
}, 1000);
}, 2000);
}
// Logs functionality
showLogsModal() {
this.showModal("logsModal");
this.loadLogStats();
this.loadLogFiles();
this.loadLogConfig();
}
closeLogs() {
this.hideModal("logsModal");
}
async loadLogStats() {
try {
const response = await fetch('/prompt_manager/logs/stats');
const data = await response.json();
if (data.success) {
const stats = data.stats;
document.getElementById("logStatsTotal").textContent = stats.buffer_count || 0;
document.getElementById("logStatsErrors").textContent = stats.level_counts?.ERROR || 0;
document.getElementById("logStatsWarnings").textContent = stats.level_counts?.WARNING || 0;
document.getElementById("logStatsLevel").textContent = stats.current_level || "INFO";
document.getElementById("logStatsSize").textContent = this.formatBytes(stats.total_log_size || 0);
}
} catch (error) {
console.error("Failed to load log stats:", error);
}
}
async loadLogConfig() {
try {
const response = await fetch('/prompt_manager/logs/config');
const data = await response.json();
if (data.success) {
const config = data.config;
document.getElementById("setLogLevel").value = config.level || "INFO";
document.getElementById("consoleLogging").checked = config.console_logging !== false;
}
} catch (error) {
console.error("Failed to load log config:", error);
}
}
async loadLogFiles() {
try {
const response = await fetch('/prompt_manager/logs/files');
const data = await response.json();
if (data.success) {
const container = document.getElementById("logFilesList");
if (data.files.length === 0) {
container.innerHTML = '<div class="col-span-full text-center text-gray-500">No log files found</div>';
return;
}
container.innerHTML = data.files.map(file => `
<div class="bg-gray-700 rounded-lg p-3">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-200">${file.filename}</span>
${file.is_main ? '<span class="bg-blue-500 text-xs px-2 py-1 rounded">Active</span>' : ''}
</div>
<div class="text-xs text-gray-400 mb-2">
Size: ${this.formatBytes(file.size)} | Modified: ${new Date(file.modified).toLocaleString()}
</div>
<button onclick="window.admin.downloadLogFile('${file.filename}')"
class="w-full px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-xs rounded transition-colors">
📥 Download
</button>
</div>
`).join('');
}
} catch (error) {
console.error("Failed to load log files:", error);
}
}
async refreshLogs() {
const level = document.getElementById("logLevel").value;
const limit = parseInt(document.getElementById("logLimit").value);
try {
const url = new URL('/prompt_manager/logs', window.location.origin);
if (level) url.searchParams.set('level', level);
url.searchParams.set('limit', limit.toString());
const response = await fetch(url);
const data = await response.json();
if (data.success) {
this.displayLogs(data.logs);
this.showNotification(`Loaded ${data.logs.length} log entries`, "success");
} else {
this.showNotification(`Failed to load logs: ${data.error}`, "error");
}
} catch (error) {
console.error("Failed to refresh logs:", error);
this.showNotification("Failed to refresh logs", "error");
}
}
displayLogs(logs) {
const container = document.getElementById("logsContainer");
if (logs.length === 0) {
container.innerHTML = '<div class="text-center py-8 text-gray-500">No logs found</div>';
return;
}
container.innerHTML = logs.map(log => {
const levelColors = {
DEBUG: 'text-gray-400',
INFO: 'text-blue-400',
WARNING: 'text-yellow-400',
ERROR: 'text-red-400',
CRITICAL: 'text-red-600'
};
const levelColor = levelColors[log.level] || 'text-gray-400';
const timestamp = new Date(log.timestamp).toLocaleString();
return `
<div class="border-l-2 border-gray-600 pl-3 py-1 hover:bg-gray-800 transition-colors">
<div class="flex items-start space-x-2 text-sm">
<span class="text-gray-500 text-xs font-mono w-24 flex-shrink-0">${timestamp.split(' ')[1]}</span>
<span class="${levelColor} font-semibold w-16 flex-shrink-0">${log.level}</span>
<span class="text-purple-400 text-xs w-32 flex-shrink-0">${log.logger}</span>
<span class="text-gray-300 flex-1">${log.message}</span>
</div>
<div class="text-xs text-gray-500 ml-44">
${log.filename}:${log.lineno}
</div>
</div>
`;
}).join('');
// Auto-scroll to bottom
container.scrollTop = container.scrollHeight;
}
async downloadLogs() {
const level = document.getElementById("logLevel").value;
const limit = parseInt(document.getElementById("logLimit").value);
try {
const url = new URL('/prompt_manager/logs', window.location.origin);
if (level) url.searchParams.set('level', level);
url.searchParams.set('limit', limit.toString());
const response = await fetch(url);
const data = await response.json();
if (data.success) {
const logText = data.logs.map(log =>
`${log.timestamp} [${log.level}] ${log.logger}: ${log.message} (${log.filename}:${log.lineno})`
).join('\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url2 = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url2;
a.download = `prompt_manager_logs_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url2);
this.showNotification("Logs downloaded successfully", "success");
} else {
this.showNotification(`Failed to download logs: ${data.error}`, "error");
}
} catch (error) {
console.error("Failed to download logs:", error);
this.showNotification("Failed to download logs", "error");
}
}
async downloadLogFile(filename) {
try {
const response = await fetch(`/prompt_manager/logs/download/${encodeURIComponent(filename)}`);
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.showNotification(`Downloaded ${filename}`, "success");
} else {
this.showNotification(`Failed to download ${filename}`, "error");
}
} catch (error) {
console.error("Failed to download log file:", error);
this.showNotification("Failed to download log file", "error");
}
}
async clearLogs() {
if (!confirm("Are you sure you want to clear all log files? This cannot be undone.")) {
return;
}
try {
const response = await fetch('/prompt_manager/logs/truncate', {
method: 'POST'
});
const data = await response.json();
if (data.success) {
this.showNotification(data.message, "success");
this.refreshLogs();
this.loadLogStats();
this.loadLogFiles();
} else {
this.showNotification(`Failed to clear logs: ${data.error}`, "error");
}
} catch (error) {
console.error("Failed to clear logs:", error);
this.showNotification("Failed to clear logs", "error");
}
}
async updateLogConfig() {
const level = document.getElementById("setLogLevel").value;
const consoleLogging = document.getElementById("consoleLogging").checked;
try {
const response = await fetch('/prompt_manager/logs/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
level: level,
console_logging: consoleLogging
})
});
const data = await response.json();
if (data.success) {
this.showNotification("Log configuration updated", "success");
this.loadLogStats();
} else {
this.showNotification(`Failed to update config: ${data.error}`, "error");
}
} catch (error) {
console.error("Failed to update log config:", error);
this.showNotification("Failed to update log configuration", "error");
}
}
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// Pagination methods
updatePaginationControls() {
const { currentPage, totalPages, total, limit } = this.pagination;
// Update pagination info
document.getElementById("currentPage").textContent = currentPage;
document.getElementById("totalPages").textContent = totalPages;
document.getElementById("totalResults").textContent = total;
// Calculate showing range
const start = ((currentPage - 1) * limit) + 1;
const end = Math.min(currentPage * limit, total);
document.getElementById("showingStart").textContent = start;
document.getElementById("showingEnd").textContent = end;
// Update results count
document.getElementById("resultsCount").textContent = `${this.prompts.length} of ${total}`;
// Update button states
document.getElementById("firstPageBtn").disabled = currentPage === 1;
document.getElementById("prevPageBtn").disabled = currentPage === 1;
document.getElementById("nextPageBtn").disabled = currentPage === totalPages;
document.getElementById("lastPageBtn").disabled = currentPage === totalPages;
}
async goToPage(page) {
if (page < 1 || page > this.pagination.totalPages || page === this.pagination.currentPage) {
return;
}
await this.loadRecentPrompts(page);
// Scroll to top of results after page loads
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async changeLimit(newLimit) {
this.pagination.limit = newLimit;
this.pagination.currentPage = 1; // Reset to first page
await this.loadRecentPrompts(1);
}
// Metadata functionality
openMetadataViewer() {
window.open('/prompt_manager/gallery.html', '_blank');
}
// Gallery functionality
openGallery() {
window.open('/prompt_manager/gallery', '_blank');
}
async parsePNGMetadata(arrayBuffer) {
const dataView = new DataView(arrayBuffer);
let offset = 8; // Skip PNG signature
const metadata = {};
let chunkCount = 0;
console.log('Starting PNG metadata parsing...');
while (offset < arrayBuffer.byteLength - 8) {
const length = dataView.getUint32(offset);
const type = new TextDecoder().decode(arrayBuffer.slice(offset + 4, offset + 8));
chunkCount++;
console.log(`Chunk ${chunkCount}: type=${type}, length=${length}`);
if (type === 'tEXt' || type === 'iTXt' || type === 'zTXt') {
const chunkData = arrayBuffer.slice(offset + 8, offset + 8 + length);
let text;
if (type === 'tEXt') {
text = new TextDecoder().decode(chunkData);
} else if (type === 'iTXt') {
// iTXt format: keyword\0compression\0language\0translated_keyword\0text
const textData = new TextDecoder().decode(chunkData);
const parts = textData.split('\0');
console.log(`iTXt parts count: ${parts.length}, first part: ${parts[0]}`);
if (parts.length >= 5) {
metadata[parts[0]] = parts[4];
}
text = textData;
} else if (type === 'zTXt') {
// zTXt is compressed - basic parsing (might need proper decompression)
text = new TextDecoder().decode(chunkData);
}
// Parse the text chunk for key-value pairs
const nullIndex = text.indexOf('\0');
if (nullIndex !== -1) {
const key = text.substring(0, nullIndex);
const value = text.substring(nullIndex + 1);
console.log(`Found metadata: ${key} = ${value.substring(0, 100)}...`);
metadata[key] = value;
}
}
offset += 8 + length + 4; // Move to next chunk (8 = length + type, 4 = CRC)
}
console.log(`Parsed ${chunkCount} chunks, found ${Object.keys(metadata).length} metadata items`);
return metadata;
}
extractComfyUIData(metadata) {
// Look for ComfyUI workflow data in various possible fields
let workflowData = null;
let promptData = null;
// Common ComfyUI metadata field names
const workflowFields = ['workflow', 'Workflow', 'comfy', 'ComfyUI'];
const promptFields = ['prompt', 'Prompt', 'parameters', 'Parameters'];
for (const field of workflowFields) {
if (metadata[field]) {
try {
// Clean NaN values from JSON string before parsing
let cleanedJson = metadata[field];
cleanedJson = cleanedJson.replace(/:\s*NaN\b/g, ': null');
cleanedJson = cleanedJson.replace(/\bNaN\b/g, 'null');
workflowData = JSON.parse(cleanedJson);
console.log(`Successfully parsed workflow field: ${field}`);
break;
} catch (e) {
console.log('Failed to parse workflow field:', field, e.message);
}
}
}
for (const field of promptFields) {
if (metadata[field]) {
try {
// Clean NaN values from JSON string before parsing
let cleanedJson = metadata[field];
cleanedJson = cleanedJson.replace(/:\s*NaN\b/g, ': null');
cleanedJson = cleanedJson.replace(/\bNaN\b/g, 'null');
promptData = JSON.parse(cleanedJson);
console.log(`Successfully parsed prompt field: ${field}`);
break;
} catch (e) {
console.log('Failed to parse prompt field:', field, e.message);
console.log('Raw data:', metadata[field].substring(0, 200) + '...');
}
}
}
return { workflow: workflowData, prompt: promptData };
}
updateMetadataPanel(comfyData, imageSrc) {
const metadataContent = document.getElementById('metadataContent');
if (!metadataContent) return;
// Get the actual file path from current image data
let filePath = imageSrc; // fallback to URL
if (this.currentGalleryImages && this.currentImageIndex !== null && this.currentGalleryImages[this.currentImageIndex]) {
const currentImage = this.currentGalleryImages[this.currentImageIndex];
filePath = currentImage.image_path || currentImage.filename || imageSrc;
}
// Extract information from ComfyUI workflow
let checkpoint = 'Unknown';
let positivePrompt = 'No prompt found';
let negativePrompt = 'No negative prompt found';
let steps = 'Unknown';
let cfgScale = 'Unknown';
let sampler = 'Unknown';
let seed = 'Unknown';
// Parse prompt data first (more reliable for actual generation parameters)
if (comfyData.prompt) {
console.log('Parsing prompt data...', Object.keys(comfyData.prompt).length, 'nodes');
const promptNodes = comfyData.prompt;
for (const nodeId in promptNodes) {
const node = promptNodes[nodeId];
console.log(`Node ${nodeId}:`, node.class_type, node);
// Checkpoint
if (node.class_type === 'CheckpointLoaderSimple' && node.inputs) {
checkpoint = node.inputs.ckpt_name || checkpoint;
console.log('Found checkpoint:', checkpoint);
}
// Prompts - need to identify which is positive vs negative
if (node.class_type === 'PromptManager' && node.inputs && node.inputs.text) {
// PromptManager typically contains the positive prompt
const promptValue = typeof node.inputs.text === 'string' ? node.inputs.text : (Array.isArray(node.inputs.text) ? node.inputs.text[0] : String(node.inputs.text));
positivePrompt = promptValue;
console.log('Found PromptManager with text:', promptValue.substring(0, 100));
}
if (node.class_type === 'CLIPTextEncode' && node.inputs && node.inputs.text) {
// Check if this looks like a negative prompt
const textValue = typeof node.inputs.text === 'string' ? node.inputs.text : (Array.isArray(node.inputs.text) ? node.inputs.text[0] : String(node.inputs.text));
console.log('Found CLIPTextEncode:', textValue.substring(0, 50));
const text = textValue.toLowerCase();
if (text.includes('bad anatomy') || text.includes('unfinished') ||
text.includes('censored') || text.includes('weird anatomy') ||
text.includes('negative') || text.includes('embedding:')) {
negativePrompt = textValue;
console.log('Found negative prompt:', textValue.substring(0, 100));
} else if (positivePrompt === 'No prompt found') {
// If we haven't found a positive prompt yet, this might be it
positivePrompt = textValue;
console.log('Found potential positive prompt:', textValue.substring(0, 100));
}
}
// Sampling parameters
if (node.class_type === 'KSampler' && node.inputs) {
seed = node.inputs.seed || seed;
steps = node.inputs.steps || steps;
cfgScale = node.inputs.cfg || cfgScale;
sampler = node.inputs.sampler_name || sampler;
console.log('Found sampler params:', {seed, steps, cfgScale, sampler});
}
}
} else {
console.log('No prompt data found, will try workflow fallback');
}
// Store the current metadata for copying
this.currentMetadata = {
positivePrompt,
negativePrompt,
checkpoint,
steps,
cfgScale,
sampler,
seed,
workflow: comfyData.workflow,
prompt: comfyData.prompt
};
// Update the HTML
metadataContent.innerHTML = `
<!-- File Path -->
<div>
<h2 class="text-sm font-medium text-gray-300 mb-2">File Path</h2>
<div class="text-sm text-blue-400 hover:text-blue-300 cursor-pointer bg-gray-800 p-2 rounded break-all" onclick="window.admin.copyToClipboard('${filePath}')">
${filePath}
</div>
</div>
<!-- Resources used -->
<div>
<h2 class="text-sm font-medium text-gray-300 mb-2">Resources used</h2>
<div class="flex items-center justify-between">
<div>
<div class="text-blue-400 hover:text-blue-300 cursor-pointer">${checkpoint}</div>
<div class="text-xs text-gray-500">ComfyUI Generated</div>
</div>
<span class="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">CHECKPOINT</span>
</div>
</div>
<!-- Prompt -->
<div>
<div class="flex items-center gap-2 mb-2">
<h2 class="text-sm font-medium text-gray-300">Prompt</h2>
<span class="px-2 py-1 text-xs bg-orange-600 text-orange-100 rounded">COMFYUI</span>
<button class="ml-auto text-gray-400 hover:text-gray-300" onclick="window.admin.copyPrompt('positive')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
<div class="text-sm text-gray-300 bg-gray-800 p-3 rounded max-h-32 overflow-y-auto">
${positivePrompt.substring(0, 200)}${positivePrompt.length > 200 ? '...' : ''}
</div>
${positivePrompt.length > 200 ? '<button class="text-blue-400 hover:text-blue-300 text-sm mt-1" onclick="window.admin.showFullPrompt(\'positive\')">Show more</button>' : ''}
</div>
<!-- Negative prompt -->
<div>
<div class="flex items-center justify-between mb-2">
<h2 class="text-sm font-medium text-gray-300">Negative prompt</h2>
<button class="text-gray-400 hover:text-gray-300" onclick="window.admin.copyPrompt('negative')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
<div class="text-sm text-gray-300 bg-gray-800 p-3 rounded max-h-32 overflow-y-auto">
${negativePrompt.substring(0, 200)}${negativePrompt.length > 200 ? '...' : ''}
</div>
${negativePrompt.length > 200 ? '<button class="text-blue-400 hover:text-blue-300 text-sm mt-1" onclick="window.admin.showFullPrompt(\'negative\')">Show more</button>' : ''}
</div>
<!-- Other metadata -->
<div>
<h2 class="text-sm font-medium text-gray-300 mb-3">Other metadata</h2>
<div class="flex flex-wrap gap-2">
<span class="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">CFG SCALE: ${cfgScale}</span>
<span class="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">STEPS: ${steps}</span>
<span class="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">SAMPLER: ${sampler}</span>
</div>
<div class="mt-2">
<span class="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">SEED: ${seed}</span>
</div>
</div>
<!-- Raw Workflow Data -->
<div>
<h2 class="text-sm font-medium text-gray-300 mb-2">ComfyUI Workflow</h2>
<div class="flex items-center gap-2">
<button class="text-blue-400 hover:text-blue-300 text-sm" onclick="window.admin.showWorkflowData()">View Raw Workflow JSON</button>
<button class="text-blue-400 hover:text-blue-300" onclick="window.admin.downloadWorkflowJSON()" title="Download JSON">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</button>
</div>
</div>
`;
}
showMetadataError() {
const metadataContent = document.getElementById('metadataContent');
if (!metadataContent) return;
metadataContent.innerHTML = `
<div class="text-center text-red-400 py-8">
<svg class="w-16 h-16 mx-auto mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-lg mb-2">Error Loading Metadata</p>
<p class="text-sm">Could not extract ComfyUI metadata from this image</p>
</div>
`;
}
async copyPrompt(type) {
if (!this.currentMetadata) {
this.showNotification('❌ No metadata available for copying', 'error');
return;
}
const text = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt;
if (!text || text === 'No prompt found' || text === 'No negative prompt found') {
this.showNotification(`❌ No ${type} prompt available`, 'error');
return;
}
await this.copyToClipboard(text);
}
async tryFallbackMetadata(imageSrc) {
console.log('Trying fallback metadata extraction for:', imageSrc);
try {
// Try to get prompt from current gallery image data
let fallbackPrompt = 'No prompt found';
if (this.currentGalleryImages && this.currentImageIndex !== null && this.currentGalleryImages[this.currentImageIndex]) {
const currentImage = this.currentGalleryImages[this.currentImageIndex];
console.log('Current image data:', currentImage);
// If we have prompt_id, try to get the prompt from our local prompts array
if (currentImage.prompt_id && this.prompts) {
const prompt = this.prompts.find(p => p.id === currentImage.prompt_id);
if (prompt) {
fallbackPrompt = prompt.text;
console.log('Found prompt from database:', fallbackPrompt.substring(0, 100));
}
}
}
// Set fallback metadata
this.currentMetadata = {
positivePrompt: fallbackPrompt,
negativePrompt: 'No negative prompt found',
checkpoint: 'Unknown',
steps: 'Unknown',
cfgScale: 'Unknown',
sampler: 'Unknown',
seed: 'Unknown',
workflow: null,
prompt: null
};
// Update metadata panel with fallback data
this.updateMetadataPanel({}, imageSrc);
} catch (error) {
console.error('Fallback metadata extraction failed:', error);
this.showMetadataError();
// Set empty metadata as last resort
this.currentMetadata = {
positivePrompt: 'No prompt found',
negativePrompt: 'No negative prompt found',
checkpoint: 'Unknown',
steps: 'Unknown',
cfgScale: 'Unknown',
sampler: 'Unknown',
seed: 'Unknown',
workflow: null,
prompt: null
};
}
}
async copyAllMetadata() {
if (!this.currentMetadata) return;
const allData = `Checkpoint: ${this.currentMetadata.checkpoint || 'Unknown'}
Positive Prompt: ${this.currentMetadata.positivePrompt}
Negative Prompt: ${this.currentMetadata.negativePrompt}
Steps: ${this.currentMetadata.steps || 'Unknown'}
CFG Scale: ${this.currentMetadata.cfgScale || 'Unknown'}
Sampler: ${this.currentMetadata.sampler || 'Unknown'}
Seed: ${this.currentMetadata.seed || 'Unknown'}`;
await this.copyToClipboard(allData);
}
showFullPrompt(type) {
if (!this.currentMetadata) return;
const prompt = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt;
const newWindow = window.open('', '_blank');
newWindow.document.write(`
<html>
<head><title>${type.charAt(0).toUpperCase() + type.slice(1)} Prompt</title></head>
<body style="background: #111; color: #fff; font-family: monospace; padding: 20px;">
<h2>${type.charAt(0).toUpperCase() + type.slice(1)} Prompt</h2>
<div style="background: #222; padding: 15px; border-radius: 5px; white-space: pre-wrap; line-height: 1.5;">${prompt}</div>
<button onclick="navigator.clipboard.writeText(\`${prompt.replace(/`/g, '\\`')}\`).then(() => alert('Copied!'))" style="margin-top: 20px; padding: 10px 20px; background: #444; color: #fff; border: none; border-radius: 5px; cursor: pointer;">Copy to Clipboard</button>
</body>
</html>
`);
}
showWorkflowData() {
if (!this.currentMetadata || !this.currentMetadata.workflow) return;
const newWindow = window.open('', '_blank');
newWindow.document.write(`
<html>
<head><title>ComfyUI Workflow Data</title></head>
<body style="background: #111; color: #fff; font-family: monospace; padding: 20px;">
<h2>ComfyUI Workflow JSON</h2>
<pre style="background: #222; padding: 15px; border-radius: 5px; overflow: auto;">${JSON.stringify(this.currentMetadata.workflow, null, 2)}</pre>
</body>
</html>
`);
}
downloadWorkflowJSON() {
if (!this.currentMetadata || !this.currentMetadata.workflow) return;
const dataStr = JSON.stringify(this.currentMetadata.workflow, null, 2);
const dataBlob = new Blob([dataStr], {type: 'application/json'});
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = 'comfyui_workflow.json';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
this.showNotification('Copied to clipboard!', 'success');
} catch (err) {
console.error('Copy failed:', err);
this.showNotification('Copy failed', 'error');
}
}
async parsePNGMetadata(arrayBuffer) {
const dataView = new DataView(arrayBuffer);
let offset = 8; // Skip PNG signature
const metadata = {};
while (offset < arrayBuffer.byteLength - 8) {
const length = dataView.getUint32(offset);
const type = new TextDecoder().decode(arrayBuffer.slice(offset + 4, offset + 8));
if (type === 'tEXt' || type === 'iTXt' || type === 'zTXt') {
const chunkData = arrayBuffer.slice(offset + 8, offset + 8 + length);
let text;
if (type === 'tEXt') {
text = new TextDecoder().decode(chunkData);
} else if (type === 'iTXt') {
// iTXt format: keyword\0compression\0language\0translated_keyword\0text
const textData = new TextDecoder().decode(chunkData);
const parts = textData.split('\0');
if (parts.length >= 5) {
metadata[parts[0]] = parts[4];
}
text = textData;
} else if (type === 'zTXt') {
// zTXt is compressed - basic parsing (might need proper decompression)
text = new TextDecoder().decode(chunkData);
}
// Parse the text chunk for key-value pairs
const nullIndex = text.indexOf('\0');
if (nullIndex !== -1) {
const key = text.substring(0, nullIndex);
const value = text.substring(nullIndex + 1);
metadata[key] = value;
}
}
offset += 8 + length + 4; // Move to next chunk (8 = length + type, 4 = CRC)
}
return metadata;
}
extractComfyUIData(metadata) {
// Look for ComfyUI workflow data in various possible fields
let workflowData = null;
let promptData = null;
// Common ComfyUI metadata field names
const workflowFields = ['workflow', 'Workflow', 'comfy', 'ComfyUI'];
const promptFields = ['prompt', 'Prompt', 'parameters', 'Parameters'];
for (const field of workflowFields) {
if (metadata[field]) {
try {
// Clean NaN values from JSON string before parsing
let cleanedJson = metadata[field];
cleanedJson = cleanedJson.replace(/:\s*NaN\b/g, ': null');
cleanedJson = cleanedJson.replace(/\bNaN\b/g, 'null');
workflowData = JSON.parse(cleanedJson);
break;
} catch (e) {
console.log('Failed to parse workflow field:', field);
}
}
}
for (const field of promptFields) {
if (metadata[field]) {
try {
// Clean NaN values from JSON string before parsing
let cleanedJson = metadata[field];
cleanedJson = cleanedJson.replace(/:\s*NaN\b/g, ': null');
cleanedJson = cleanedJson.replace(/\bNaN\b/g, 'null');
promptData = JSON.parse(cleanedJson);
break;
} catch (e) {
console.log('Failed to parse prompt field:', field);
}
}
}
return { workflow: workflowData, prompt: promptData };
}
async extractImageMetadata(imageUrl) {
try {
// Fetch the image as an array buffer
const response = await fetch(imageUrl);
const arrayBuffer = await response.arrayBuffer();
// Parse PNG metadata
const metadata = await this.parsePNGMetadata(arrayBuffer);
const comfyData = this.extractComfyUIData(metadata);
return this.parseWorkflowData(comfyData, imageUrl);
} catch (error) {
console.error('Error extracting metadata:', error);
return null;
}
}
parseWorkflowData(comfyData, imagePath) {
// Extract information from ComfyUI workflow
let checkpoint = 'Unknown';
let positivePrompt = 'No prompt found';
let negativePrompt = 'No negative prompt found';
let steps = 'Unknown';
let cfgScale = 'Unknown';
let sampler = 'Unknown';
let seed = 'Unknown';
// Parse prompt data first (more reliable for actual generation parameters)
if (comfyData.prompt) {
const promptNodes = comfyData.prompt;
for (const nodeId in promptNodes) {
const node = promptNodes[nodeId];
// Checkpoint
if (node.class_type === 'CheckpointLoaderSimple' && node.inputs) {
checkpoint = node.inputs.ckpt_name || checkpoint;
}
// Prompts - need to identify which is positive vs negative
if (node.class_type === 'PromptManager' && node.inputs && node.inputs.text) {
// PromptManager typically contains the positive prompt
const promptValue = typeof node.inputs.text === 'string' ? node.inputs.text : (Array.isArray(node.inputs.text) ? node.inputs.text[0] : String(node.inputs.text));
positivePrompt = promptValue;
}
if (node.class_type === 'CLIPTextEncode' && node.inputs && node.inputs.text) {
// Check if this looks like a negative prompt
const textValue = typeof node.inputs.text === 'string' ? node.inputs.text : (Array.isArray(node.inputs.text) ? node.inputs.text[0] : String(node.inputs.text));
const text = textValue.toLowerCase();
if (text.includes('bad anatomy') || text.includes('unfinished') ||
text.includes('censored') || text.includes('weird anatomy') ||
text.includes('negative') || text.includes('embedding:')) {
negativePrompt = textValue;
} else if (positivePrompt === 'No prompt found') {
// If we haven't found a positive prompt yet, this might be it
positivePrompt = textValue;
}
}
// Sampling parameters
if (node.class_type === 'KSampler' && node.inputs) {
seed = node.inputs.seed || seed;
steps = node.inputs.steps || steps;
cfgScale = node.inputs.cfg || cfgScale;
sampler = node.inputs.sampler_name || sampler;
}
}
}
// Parse workflow data (this is what we actually have in your case)
if (comfyData.workflow && comfyData.workflow.nodes) {
const nodes = comfyData.workflow.nodes;
console.log('DEBUG: Total nodes found:', nodes.length);
// Debug: Log all nodes to understand the structure
nodes.forEach((node, index) => {
console.log(`Node ${index} (ID: ${node.id}):`, {
type: node.type,
title: node.title,
widgets_values: node.widgets_values
});
});
// Look for checkpoint loader
const checkpointNode = nodes.find(node =>
node.type === 'CheckpointLoaderSimple' ||
node.type === 'CheckpointLoader'
);
if (checkpointNode && checkpointNode.widgets_values && checkpointNode.widgets_values[0]) {
checkpoint = checkpointNode.widgets_values[0];
}
// Look for prompts in workflow nodes
const textEncodeNodes = nodes.filter(node => node.type === 'CLIPTextEncode');
const promptManagerNodes = nodes.filter(node => node.type === 'PromptManager');
// Check PromptManager first for positive prompt
if (promptManagerNodes.length > 0 && promptManagerNodes[0].widgets_values && promptManagerNodes[0].widgets_values[0]) {
positivePrompt = promptManagerNodes[0].widgets_values[0];
}
// For negative prompt, look for CLIPTextEncode that contains negative keywords
for (const node of textEncodeNodes) {
if (node.widgets_values && node.widgets_values[0]) {
const text = node.widgets_values[0].toLowerCase();
if (text.includes('bad anatomy') || text.includes('unfinished') ||
text.includes('censored') || text.includes('weird anatomy') ||
text.includes('negative') || text.includes('embedding:')) {
negativePrompt = node.widgets_values[0];
break;
}
}
}
// Extract generation parameters from their source nodes
// Look for specific node types with better fallback logic
// CFG Scale - prioritize Float nodes with title 'CFG', then other sources
console.log('DEBUG: Looking for CFG scale...');
// First priority: Float node with title 'CFG'
const cfgFloatNode = nodes.find(node => node.type === 'Float' && node.title === 'CFG');
if (cfgFloatNode && cfgFloatNode.widgets_values && cfgFloatNode.widgets_values[0]) {
cfgScale = cfgFloatNode.widgets_values[0];
console.log(`DEBUG: Found CFG in Float node: ${cfgScale}`);
}
// If not found, look for specific value of 7 (your target CFG)
if (cfgScale === 'Unknown') {
for (const node of nodes) {
if (node.widgets_values && Array.isArray(node.widgets_values)) {
for (let i = 0; i < node.widgets_values.length; i++) {
const value = node.widgets_values[i];
if (value === 7) { // Target the specific value you mentioned
cfgScale = value;
console.log(`DEBUG: Found target CFG value 7 in node ${node.id} (${node.type}) at index ${i}`);
break;
}
}
if (cfgScale !== 'Unknown') break;
}
}
}
// Fallback: any reasonable CFG value
if (cfgScale === 'Unknown') {
for (const node of nodes) {
if (node.widgets_values && Array.isArray(node.widgets_values)) {
for (let i = 0; i < node.widgets_values.length; i++) {
const value = node.widgets_values[i];
if (typeof value === 'number' && value > 1 && value <= 30) {
console.log(`DEBUG: Found potential CFG ${value} in node ${node.id} (${node.type}) at index ${i}`);
cfgScale = value;
console.log(`DEBUG: Set CFG scale to ${value}`);
break;
}
}
if (cfgScale !== 'Unknown') break;
}
}
}
// Steps - prioritize Int nodes with title 'Steps', then target value 30
console.log('DEBUG: Looking for steps...');
// First priority: Int node with title 'Steps'
const stepsIntNode = nodes.find(node => node.type === 'Int' && node.title === 'Steps');
if (stepsIntNode && stepsIntNode.widgets_values && stepsIntNode.widgets_values[0]) {
steps = stepsIntNode.widgets_values[0];
console.log(`DEBUG: Found steps in Int node: ${steps}`);
}
// If not found, look for specific value of 30 (your target steps)
if (steps === 'Unknown') {
for (const node of nodes) {
if (node.widgets_values && Array.isArray(node.widgets_values)) {
for (let i = 0; i < node.widgets_values.length; i++) {
const value = node.widgets_values[i];
if (value === 30) { // Target the specific value you mentioned
steps = value;
console.log(`DEBUG: Found target steps value 30 in node ${node.id} (${node.type}) at index ${i}`);
break;
}
}
if (steps !== 'Unknown') break;
}
}
}
// Fallback: reasonable step values, prefer 10-150 range
if (steps === 'Unknown') {
for (const node of nodes) {
if (node.widgets_values && Array.isArray(node.widgets_values)) {
for (let i = 0; i < node.widgets_values.length; i++) {
const value = node.widgets_values[i];
if (typeof value === 'number' && value >= 10 && value <= 150) {
console.log(`DEBUG: Found good steps value ${value} in node ${node.id} (${node.type}) at index ${i}`);
steps = value;
console.log(`DEBUG: Set steps to ${value}`);
break;
}
}
if (steps !== 'Unknown') break;
}
}
}
// Sampler - look for valid ComfyUI samplers
console.log('DEBUG: Looking for sampler...');
const validSamplers = [
'euler', 'euler_ancestral', 'heun', 'dpm_2', 'dpm_2_ancestral', 'lms',
'dpm_fast', 'dpm_adaptive', 'dpmpp_2s_ancestral', 'dpmpp_sde', 'dpmpp_sde_gpu',
'dpmpp_2m', 'dpmpp_2m_sde', 'dpmpp_2m_sde_gpu', 'dpmpp_3m_sde', 'dpmpp_3m_sde_gpu',
'ddim', 'uni_pc', 'uni_pc_bh2'
];
for (const node of nodes) {
if (node.widgets_values && Array.isArray(node.widgets_values)) {
for (let i = 0; i < node.widgets_values.length; i++) {
const value = node.widgets_values[i];
if (typeof value === 'string') {
const samplerValue = value.toLowerCase();
if (validSamplers.some(validSampler => samplerValue.includes(validSampler))) {
console.log(`DEBUG: Found sampler ${value} in node ${node.id} (${node.type}) at index ${i}`);
if (sampler === 'Unknown') {
sampler = value;
console.log(`DEBUG: Set sampler to ${value}`);
}
}
}
}
}
}
// Seed - look for large numbers that could be seeds
console.log('DEBUG: Looking for seed...');
for (const node of nodes) {
if (node.widgets_values && Array.isArray(node.widgets_values)) {
for (let i = 0; i < node.widgets_values.length; i++) {
const value = node.widgets_values[i];
if (typeof value === 'number' && value > 100000) {
console.log(`DEBUG: Found potential seed ${value} in node ${node.id} (${node.type}) at index ${i}`);
if (seed === 'Unknown') {
seed = value;
console.log(`DEBUG: Set seed to ${value}`);
}
}
}
}
}
// If we still don't have sampling params, look at KSampler widgets_values directly
const ksamplerNode = nodes.find(node =>
node.type === 'KSampler' || node.type === 'KSamplerAdvanced'
);
if (ksamplerNode && ksamplerNode.widgets_values) {
const widgets = ksamplerNode.widgets_values;
// KSampler widgets_values order: [seed, control_mode, steps, cfg, sampler_name, scheduler, denoise, ...]
if (seed === 'Unknown' && widgets[0]) seed = widgets[0];
if (steps === 'Unknown' && widgets[2]) steps = widgets[2];
if (cfgScale === 'Unknown' && widgets[3]) cfgScale = widgets[3];
if (sampler === 'Unknown' && widgets[4]) sampler = widgets[4];
}
}
return {
positivePrompt,
negativePrompt,
checkpoint,
steps,
cfgScale,
sampler,
seed,
workflow: comfyData.workflow,
prompt: comfyData.prompt,
imagePath
};
}
addMetadataSidebar() {
const viewerContainer = document.querySelector('.viewer-container');
if (!viewerContainer || document.getElementById('metadata-sidebar')) return;
const sidebar = document.createElement('div');
sidebar.id = 'metadata-sidebar';
sidebar.className = 'metadata-sidebar';
sidebar.innerHTML = `
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-gray-700">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<h1 class="text-lg font-medium text-white">Generation data</h1>
</div>
<button class="text-xs text-blue-400 hover:text-blue-300 transition-colors metadata-copy-all">
📋 COPY ALL
</button>
</div>
<!-- Scrollable Content -->
<div id="metadata-content" class="flex-1 overflow-y-auto p-4 space-y-6">
<!-- Initial placeholder content -->
<div class="text-center text-gray-500 py-8">
<svg class="w-16 h-16 mx-auto mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p class="text-lg mb-2">Loading Metadata...</p>
<p class="text-sm">Extracting ComfyUI workflow data</p>
</div>
</div>
`;
viewerContainer.appendChild(sidebar);
this.attachMetadataEventListeners();
}
async loadMetadataForImage(imgElement) {
const imageUrl = imgElement.getAttribute('data-original') || imgElement.src;
if (!imageUrl) return;
const metadataContent = document.getElementById('metadata-content');
if (!metadataContent) return;
// Show loading state
metadataContent.innerHTML = `
<div class="metadata-loading">
<div class="text-center text-gray-500 py-8">
<svg class="w-16 h-16 mx-auto mb-4 opacity-30 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
<p class="text-lg mb-2">Loading Metadata...</p>
<p class="text-sm">Extracting ComfyUI workflow data</p>
</div>
</div>
`;
try {
const metadata = await this.extractImageMetadata(imageUrl);
this.currentMetadata = metadata;
this.updateMetadataSidebar(metadata);
} catch (error) {
console.error('Failed to load metadata:', error);
metadataContent.innerHTML = `
<div class="text-center text-red-500 py-8">
<p class="text-lg mb-2">Failed to Load Metadata</p>
<p class="text-sm">Could not extract ComfyUI workflow data</p>
</div>
`;
}
}
updateMetadataSidebar(metadata) {
const metadataContent = document.getElementById('metadata-content');
if (!metadataContent || !metadata) return;
metadataContent.innerHTML = `
<!-- File Path -->
<div>
<h2 class="text-sm font-medium text-gray-300 mb-2">File Path</h2>
<div class="text-sm text-blue-400 hover:text-blue-300 cursor-pointer bg-gray-800 p-2 rounded break-all" data-copy-path="">
${metadata.imagePath}
</div>
</div>
<!-- Resources used -->
<div>
<h2 class="text-sm font-medium text-gray-300 mb-2">Resources used</h2>
<div class="flex items-center justify-between">
<div>
<div class="text-blue-400 hover:text-blue-300 cursor-pointer">${metadata.checkpoint}</div>
<div class="text-xs text-gray-500">ComfyUI Generated</div>
</div>
<span class="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">CHECKPOINT</span>
</div>
</div>
<!-- Prompt -->
<div>
<div class="flex items-center gap-2 mb-2">
<h2 class="text-sm font-medium text-gray-300">Prompt</h2>
<span class="px-2 py-1 text-xs bg-orange-600 text-orange-100 rounded">COMFYUI</span>
<button class="ml-auto text-gray-400 hover:text-gray-300" data-copy-type="positive">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
<div class="text-sm text-gray-300 bg-gray-800 p-3 rounded max-h-32 overflow-y-auto">
${metadata.positivePrompt.substring(0, 200)}${metadata.positivePrompt.length > 200 ? '...' : ''}
</div>
${metadata.positivePrompt.length > 200 ? '<button class="text-blue-400 hover:text-blue-300 text-sm mt-1" data-show-type="positive">Show more</button>' : ''}
</div>
<!-- Negative prompt -->
<div>
<div class="flex items-center justify-between mb-2">
<h2 class="text-sm font-medium text-gray-300">Negative prompt</h2>
<button class="text-gray-400 hover:text-gray-300" data-copy-type="negative">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
<div class="text-sm text-gray-300 bg-gray-800 p-3 rounded max-h-32 overflow-y-auto">
${metadata.negativePrompt.substring(0, 200)}${metadata.negativePrompt.length > 200 ? '...' : ''}
</div>
${metadata.negativePrompt.length > 200 ? '<button class="text-blue-400 hover:text-blue-300 text-sm mt-1" data-show-type="negative">Show more</button>' : ''}
</div>
<!-- Other metadata -->
<div>
<h2 class="text-sm font-medium text-gray-300 mb-3">Other metadata</h2>
<div class="flex flex-wrap gap-2">
<span class="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">CFG SCALE: ${metadata.cfgScale}</span>
<span class="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">STEPS: ${metadata.steps}</span>
<span class="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">SAMPLER: ${metadata.sampler}</span>
</div>
<div class="mt-2">
<span class="px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">SEED: ${metadata.seed}</span>
</div>
</div>
<!-- Raw Workflow Data -->
<div>
<h2 class="text-sm font-medium text-gray-300 mb-2">ComfyUI Workflow</h2>
<div class="flex items-center gap-2">
<button class="text-blue-400 hover:text-blue-300 text-sm" data-action="show-workflow">View Raw Workflow JSON</button>
<button class="text-blue-400 hover:text-blue-300" data-action="download-workflow" title="Download JSON">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</button>
</div>
</div>
`;
// Re-attach event listeners after updating content
this.attachMetadataEventListeners();
}
attachMetadataEventListeners() {
const sidebar = document.getElementById('metadata-sidebar');
if (!sidebar) return;
// Copy buttons
const copyBtns = sidebar.querySelectorAll('[data-copy-type]');
copyBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
const type = btn.getAttribute('data-copy-type');
if (this.currentMetadata) {
const text = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt;
this.copyToClipboard(text);
}
});
});
// File path copy
const pathEl = sidebar.querySelector('[data-copy-path]');
if (pathEl) {
pathEl.addEventListener('click', () => {
if (this.currentMetadata) {
this.copyToClipboard(this.currentMetadata.imagePath);
}
});
}
// Show more buttons
const showMoreBtns = sidebar.querySelectorAll('[data-show-type]');
showMoreBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
const type = btn.getAttribute('data-show-type');
this.showFullPrompt(type);
});
});
// Workflow action buttons
const actionBtns = sidebar.querySelectorAll('[data-action]');
actionBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
const action = btn.getAttribute('data-action');
if (action === 'show-workflow') {
this.showWorkflowData();
} else if (action === 'download-workflow') {
this.downloadWorkflowJSON();
}
});
});
// Copy all button
const copyAllBtn = sidebar.querySelector('.metadata-copy-all');
if (copyAllBtn) {
copyAllBtn.addEventListener('click', () => {
this.copyAllMetadata();
});
}
}
showFullPrompt(type) {
if (this.currentMetadata) {
const prompt = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt;
const newWindow = window.open('', '_blank');
newWindow.document.write(`
<html>
<head><title>${type.charAt(0).toUpperCase() + type.slice(1)} Prompt</title></head>
<body style="background: #111; color: #fff; font-family: monospace; padding: 20px;">
<h2>${type.charAt(0).toUpperCase() + type.slice(1)} Prompt</h2>
<div style="background: #222; padding: 15px; border-radius: 5px; white-space: pre-wrap; line-height: 1.5;">${prompt}</div>
<button onclick="navigator.clipboard.writeText(\`${prompt.replace(/`/g, '\\`')}\`).then(() => alert('Copied!'))" style="margin-top: 20px; padding: 10px 20px; background: #444; color: #fff; border: none; border-radius: 5px; cursor: pointer;">Copy to Clipboard</button>
</body>
</html>
`);
}
}
showWorkflowData() {
if (this.currentMetadata && this.currentMetadata.workflow) {
const newWindow = window.open('', '_blank');
newWindow.document.write(`
<html>
<head><title>ComfyUI Workflow Data</title></head>
<body style="background: #111; color: #fff; font-family: monospace; padding: 20px;">
<h2>ComfyUI Workflow JSON</h2>
<pre style="background: #222; padding: 15px; border-radius: 5px; overflow: auto;">${JSON.stringify(this.currentMetadata.workflow, null, 2)}</pre>
</body>
</html>
`);
}
}
downloadWorkflowJSON() {
if (this.currentMetadata && this.currentMetadata.workflow) {
const dataStr = JSON.stringify(this.currentMetadata.workflow, null, 2);
const dataBlob = new Blob([dataStr], {type: 'application/json'});
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = 'comfyui_workflow.json';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
}
copyAllMetadata() {
if (!this.currentMetadata) return;
const allData = `Checkpoint: ${this.currentMetadata.checkpoint || 'Unknown'}
Positive Prompt: ${this.currentMetadata.positivePrompt}
Negative Prompt: ${this.currentMetadata.negativePrompt}
Steps: ${this.currentMetadata.steps || 'Unknown'}
CFG Scale: ${this.currentMetadata.cfgScale || 'Unknown'}
Sampler: ${this.currentMetadata.sampler || 'Unknown'}
Seed: ${this.currentMetadata.seed || 'Unknown'}`;
this.copyToClipboard(allData);
}
// ==========================================
// Auto Tag Feature Methods
// ==========================================
// State for AutoTag feature
autoTagState = {
eventSource: null,
downloadEventSource: null,
reviewImages: [],
reviewIndex: 0,
currentTags: [],
cancelled: false,
skipAllTagged: false,
retagResolve: null, // Promise resolver for retag confirmation
tagsExpanded: false // Track accordion state
};
// ==================== Add Prompt Modal ====================
showAddPromptModal() {
// Clear form
document.getElementById('addPromptText').value = '';
document.getElementById('addPromptCategory').value = '';
document.getElementById('addPromptRatingValue').value = '';
document.getElementById('addPromptNotes').value = '';
document.getElementById('addPromptProtected').checked = true;
// Clear tags
this.addPromptTags = [];
this.renderAddPromptTags();
// Reset rating stars
this.updateAddPromptRatingStars(0);
// Populate categories datalist
this.populateAddPromptCategories();
this.showModal('addPromptModal');
}
setupAddPromptModal() {
this.addPromptTags = [];
// Rating stars
const ratingContainer = document.getElementById('addPromptRating');
const ratingStars = ratingContainer.querySelectorAll('.rating-star');
ratingStars.forEach(star => {
star.addEventListener('click', () => {
const rating = parseInt(star.dataset.rating);
document.getElementById('addPromptRatingValue').value = rating;
this.updateAddPromptRatingStars(rating);
});
});
// Clear rating button
document.getElementById('clearAddPromptRatingBtn').addEventListener('click', () => {
document.getElementById('addPromptRatingValue').value = '';
this.updateAddPromptRatingStars(0);
});
// Tag input
const tagInput = document.getElementById('addPromptTagInput');
tagInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const tag = tagInput.value.trim().replace(/,/g, '');
if (tag && !this.addPromptTags.includes(tag)) {
this.addPromptTags.push(tag);
this.renderAddPromptTags();
}
tagInput.value = '';
}
});
// Tag suggestions
tagInput.addEventListener('input', () => {
this.showAddPromptTagSuggestions(tagInput.value);
});
}
updateAddPromptRatingStars(rating) {
const stars = document.querySelectorAll('#addPromptRating .rating-star');
stars.forEach((star, index) => {
if (index < rating) {
star.textContent = '★';
star.classList.add('text-yellow-400');
star.classList.remove('text-gray-500');
} else {
star.textContent = '☆';
star.classList.remove('text-yellow-400');
star.classList.add('text-gray-500');
}
});
}
renderAddPromptTags() {
const container = document.getElementById('addPromptTagsContainer');
const input = document.getElementById('addPromptTagInput');
// Remove existing tag chips
container.querySelectorAll('.tag-chip').forEach(chip => chip.remove());
// Add tag chips
this.addPromptTags.forEach(tag => {
const chip = document.createElement('span');
chip.className = 'tag-chip inline-flex items-center px-2 py-1 bg-blue-600 text-white text-xs rounded cursor-pointer hover:bg-blue-700';
chip.innerHTML = `${tag} <span class="ml-1">&times;</span>`;
chip.addEventListener('click', () => {
this.addPromptTags = this.addPromptTags.filter(t => t !== tag);
this.renderAddPromptTags();
});
container.insertBefore(chip, input);
});
}
showAddPromptTagSuggestions(query) {
const suggestionsContainer = document.getElementById('addPromptTagSuggestions');
if (!query) {
suggestionsContainer.classList.add('hidden');
return;
}
const matchingTags = this.tags.filter(tag =>
tag.toLowerCase().includes(query.toLowerCase()) &&
!this.addPromptTags.includes(tag)
).slice(0, 10);
if (matchingTags.length === 0) {
suggestionsContainer.classList.add('hidden');
return;
}
suggestionsContainer.innerHTML = matchingTags.map(tag => `
<div class="px-3 py-2 hover:bg-gray-600 cursor-pointer text-sm text-gray-200" data-tag="${tag}">
${tag}
</div>
`).join('');
suggestionsContainer.querySelectorAll('[data-tag]').forEach(el => {
el.addEventListener('click', () => {
const tag = el.dataset.tag;
if (!this.addPromptTags.includes(tag)) {
this.addPromptTags.push(tag);
this.renderAddPromptTags();
}
document.getElementById('addPromptTagInput').value = '';
suggestionsContainer.classList.add('hidden');
});
});
suggestionsContainer.classList.remove('hidden');
}
populateAddPromptCategories() {
const datalist = document.getElementById('addPromptCategoryList');
datalist.innerHTML = this.categories.map(cat => `<option value="${cat}">`).join('');
}
async saveNewPrompt() {
const text = document.getElementById('addPromptText').value.trim();
const category = document.getElementById('addPromptCategory').value.trim();
const rating = document.getElementById('addPromptRatingValue').value;
const notes = document.getElementById('addPromptNotes').value.trim();
const isProtected = document.getElementById('addPromptProtected').checked;
if (!text) {
this.showNotification('Prompt text is required', 'error');
return;
}
try {
const response = await fetch('/prompt_manager/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: text,
category: category || null,
rating: rating ? parseInt(rating) : null,
tags: this.addPromptTags,
notes: notes || null,
is_protected: isProtected
})
});
const data = await response.json();
if (data.success) {
this.showNotification('Prompt added successfully!', 'success');
this.hideModal('addPromptModal');
// Refresh the prompts list
this.search();
// Reload categories and tags in case new ones were added
this.loadCategories();
this.loadTags();
} else {
this.showNotification(data.error || 'Failed to add prompt', 'error');
}
} catch (error) {
console.error('Error adding prompt:', error);
this.showNotification('Failed to add prompt', 'error');
}
}
// ==================== End Add Prompt Modal ====================
// ==================== Film Strip Functions ====================
getImageUrl(image) {
// If the image already has a url property, use it
if (image.url) return image.url;
// Otherwise construct from image_path
if (image.image_path) {
// Extract relative path from the full path
// The image_path might be absolute, we need to serve it via the API
const path = image.image_path;
// Try to get filename for serving
const filename = image.filename || path.split(/[/\\]/).pop();
// Use the image ID if available, otherwise use the path-based serve endpoint
if (image.id) {
return `/prompt_manager/images/${image.id}/file`;
}
// Fallback: serve by path (encode the path)
return `/prompt_manager/images/serve/${encodeURIComponent(path)}`;
}
return null;
}
getThumbnailUrl(image) {
// If thumbnail_url already exists, use it
if (image.thumbnail_url) return image.thumbnail_url;
// Try to construct thumbnail URL from filename and relative_path
// Thumbnails are stored as: thumbnails/{parent_dir}/{stem}_thumb{ext}
// to avoid collisions with same-named files in different directories
if (image.filename) {
const lastDot = image.filename.lastIndexOf('.');
if (lastDot > 0) {
const stem = image.filename.substring(0, lastDot);
const ext = image.filename.substring(lastDot);
// Get parent directory from relative_path if available
let parentDir = '';
if (image.relative_path) {
const relPath = image.relative_path.replace(/\\/g, '/');
const lastSlash = relPath.lastIndexOf('/');
if (lastSlash > 0) {
parentDir = relPath.substring(0, lastSlash) + '/';
}
}
// URL encode the path components (but preserve slashes)
const encodedParentDir = parentDir.split('/').map(p => encodeURIComponent(p)).join('/');
return `/prompt_manager/images/serve/thumbnails/${encodedParentDir}${encodeURIComponent(stem)}_thumb${ext}`;
}
}
// Fall back to full image URL
return this.getImageUrl(image);
}
async loadFilmStripImages(promptId) {
try {
const response = await fetch(`/prompt_manager/prompts/${promptId}/images`);
if (response.ok) {
const data = await response.json();
if (data.success && data.images) {
return data.images;
}
}
return [];
} catch (error) {
console.error(`Error loading images for prompt ${promptId}:`, error);
return [];
}
}
createFilmStrip(images, promptId, maxThumbnails = 5) {
if (!images || images.length === 0) {
return `<div class="film-strip-empty">No images</div>`;
}
const displayImages = images.slice(0, maxThumbnails);
const remaining = images.length - maxThumbnails;
let html = `<div class="prompt-film-strip film-strip-size-medium" data-prompt-id="${promptId}">`;
displayImages.forEach((image, index) => {
const imageUrl = this.getImageUrl(image);
const thumbnailUrl = this.getThumbnailUrl(image);
if (!thumbnailUrl) {
// Skip images with no valid URL
return;
}
html += `
<div class="film-strip-thumbnail"
data-index="${index}"
data-prompt-id="${promptId}"
onclick="window.admin.openFilmStripViewer(${promptId}, ${index})">
<img src="${thumbnailUrl}"
alt="Image ${index + 1}"
loading="lazy"
onerror="this.src='${imageUrl}'; this.onerror=null;">
</div>
`;
});
if (remaining > 0) {
html += `
<div class="film-strip-thumbnail film-strip-thumbnail--more"
onclick="window.admin.viewGallery(${promptId})">
+${remaining}
</div>
`;
}
html += `</div>`;
return html;
}
async openFilmStripViewer(promptId, startIndex = 0) {
// Use the existing gallery function to open the viewer
// Store images temporarily for the viewer
const images = await this.loadFilmStripImages(promptId);
if (images.length === 0) {
this.showNotification('No images found for this prompt', 'warning');
return;
}
// Store for viewer
this.filmStripImages = images;
this.filmStripCurrentPrompt = promptId;
// Create a temporary container for ViewerJS
const container = document.createElement('div');
container.id = 'filmStripViewerContainer';
container.style.display = 'none';
images.forEach((image, index) => {
const imageUrl = this.getImageUrl(image);
if (!imageUrl) return;
const img = document.createElement('img');
img.src = imageUrl;
img.alt = image.filename || `Image ${index + 1}`;
img.dataset.caption = this.formatFilmStripCaption(image);
container.appendChild(img);
});
document.body.appendChild(container);
// Initialize ViewerJS
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, imageData) => image.alt || 'Image'],
hidden: () => {
viewer.destroy();
container.remove();
},
initialViewIndex: startIndex
});
viewer.show();
}
formatFilmStripCaption(image) {
const parts = [];
if (image.filename) parts.push(image.filename);
if (image.width && image.height) parts.push(`${image.width}x${image.height}`);
return parts.join(' | ') || 'Image';
}
async renderFilmStripForPrompt(promptId, container) {
const images = await this.loadFilmStripImages(promptId);
container.innerHTML = this.createFilmStrip(images, promptId);
}
// ==================== End Film Strip Functions ====================
async showAutoTagModal() {
this.showModal("autoTagModal");
await this.checkAutoTagModels();
}
async checkAutoTagModels() {
const ggufStatus = document.getElementById("ggufModelStatus");
const hfStatus = document.getElementById("hfModelStatus");
ggufStatus.innerHTML = '<span class="text-gray-400 text-sm">Checking...</span>';
hfStatus.innerHTML = '<span class="text-gray-400 text-sm">Checking...</span>';
try {
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success) {
// Update GGUF status
if (data.models.gguf.downloaded) {
ggufStatus.innerHTML = '<span class="text-green-400 text-sm">✓ Downloaded</span>';
} else {
ggufStatus.innerHTML = `<button onclick="window.admin.downloadModel('gguf')" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors">Download</button>`;
}
// Update HF status
if (data.models.hf.downloaded) {
hfStatus.innerHTML = '<span class="text-green-400 text-sm">✓ Downloaded</span>';
} else {
hfStatus.innerHTML = `<button onclick="window.admin.downloadModel('hf')" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded transition-colors">Download</button>`;
}
} else {
ggufStatus.innerHTML = '<span class="text-red-400 text-sm">Error</span>';
hfStatus.innerHTML = '<span class="text-red-400 text-sm">Error</span>';
}
} catch (error) {
console.error('Error checking models:', error);
ggufStatus.innerHTML = '<span class="text-red-400 text-sm">Error</span>';
hfStatus.innerHTML = '<span class="text-red-400 text-sm">Error</span>';
}
}
async downloadModel(modelType) {
const modelName = modelType === 'gguf' ? 'GGUF Model' : 'HuggingFace Model';
document.getElementById('downloadModelName').textContent = modelName;
document.getElementById('downloadStatus').textContent = 'Preparing...';
document.getElementById('downloadProgressPercent').textContent = '0%';
document.getElementById('downloadProgressBar').style.width = '0%';
this.hideModal("autoTagModal");
this.showModal("autoTagDownloadModal");
try {
this.autoTagState.downloadEventSource = new EventSource(`/prompt_manager/autotag/download/${modelType}`);
this.autoTagState.downloadEventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'downloading') {
const percent = Math.round(data.progress * 100);
document.getElementById('downloadStatus').textContent = data.message || 'Downloading...';
document.getElementById('downloadProgressPercent').textContent = `${percent}%`;
document.getElementById('downloadProgressBar').style.width = `${percent}%`;
} else if (data.status === 'complete') {
this.autoTagState.downloadEventSource.close();
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.checkAutoTagModels();
this.showNotification('Model downloaded successfully!', 'success');
} else if (data.status === 'error') {
this.autoTagState.downloadEventSource.close();
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.showNotification(`Download failed: ${data.message}`, 'error');
}
};
this.autoTagState.downloadEventSource.onerror = () => {
this.autoTagState.downloadEventSource.close();
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.showNotification('Download connection lost', 'error');
};
} catch (error) {
console.error('Download error:', error);
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
this.showNotification('Failed to start download', 'error');
}
}
cancelDownload() {
if (this.autoTagState.downloadEventSource) {
this.autoTagState.downloadEventSource.close();
this.autoTagState.downloadEventSource = null;
}
this.hideModal("autoTagDownloadModal");
this.showModal("autoTagModal");
}
async startAutoTag() {
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
const prompt = document.getElementById('autoTagPrompt').value;
const skipTagged = document.querySelector('input[name="autoTagMode"]:checked').value === 'skip';
// Check if model is downloaded
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success && !data.models[modelType].downloaded) {
this.showNotification('Please download the selected model first', 'warning');
return;
}
this.hideModal("autoTagModal");
this.showModal("autoTagProgressModal");
// Reset progress display
document.getElementById('autoTagCurrentFile').textContent = 'Loading model...';
document.getElementById('autoTagProgressPercent').textContent = '0%';
document.getElementById('autoTagProgressBar').style.width = '0%';
document.getElementById('autoTagProcessed').textContent = '0';
document.getElementById('autoTagApplied').textContent = '0';
document.getElementById('autoTagSkipped').textContent = '0';
this.autoTagState.cancelled = false;
try {
const formData = new URLSearchParams();
formData.append('model_type', modelType);
formData.append('prompt', prompt);
formData.append('skip_tagged', skipTagged ? 'true' : 'false');
this.autoTagState.eventSource = new EventSource(`/prompt_manager/autotag/start?${formData.toString()}`);
this.autoTagState.eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'progress') {
document.getElementById('autoTagCurrentFile').textContent = data.status || 'Processing...';
document.getElementById('autoTagProgressPercent').textContent = `${data.progress || 0}%`;
document.getElementById('autoTagProgressBar').style.width = `${data.progress || 0}%`;
if (data.processed !== undefined) {
document.getElementById('autoTagProcessed').textContent = data.processed;
document.getElementById('autoTagApplied').textContent = data.tagged || 0;
document.getElementById('autoTagSkipped').textContent = data.skipped || 0;
}
} else if (data.type === 'complete') {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification(`Auto tagging complete! Applied tags to ${data.tagged || 0} prompts.`, 'success');
this.search(); // Refresh the prompt list
} else if (data.type === 'error') {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification(`Auto tag error: ${data.message}`, 'error');
} else if (data.type === 'cancelled') {
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification('Auto tagging cancelled', 'info');
}
};
this.autoTagState.eventSource.onerror = (error) => {
console.error('AutoTag SSE error:', error);
console.error('EventSource readyState:', this.autoTagState.eventSource.readyState);
this.autoTagState.eventSource.close();
this.hideModal("autoTagProgressModal");
this.showNotification('Connection lost during auto tagging', 'error');
};
} catch (error) {
console.error('Auto tag error:', error);
this.hideModal("autoTagProgressModal");
this.showNotification('Failed to start auto tagging', 'error');
}
}
cancelAutoTag() {
this.autoTagState.cancelled = true;
if (this.autoTagState.eventSource) {
this.autoTagState.eventSource.close();
this.autoTagState.eventSource = null;
}
this.hideModal("autoTagProgressModal");
this.showNotification('Auto tagging cancelled', 'info');
}
async startReview() {
const modelType = document.querySelector('input[name="autoTagModel"]:checked').value;
// Check if model is downloaded
const response = await fetch('/prompt_manager/autotag/models');
const data = await response.json();
if (data.success && !data.models[modelType].downloaded) {
this.showNotification('Please download the selected model first', 'warning');
return;
}
this.hideModal("autoTagModal");
// Show loading modal while we fetch images and load model
this.showModal("autoTagLoadingModal");
document.getElementById('autoTagLoadingStatus').textContent = 'Fetching images from database...';
// Get ALL images with linked prompts from the database
try {
const scanResponse = await fetch('/prompt_manager/images/all');
const scanData = await scanResponse.json();
if (!scanData.success || !scanData.images || scanData.images.length === 0) {
this.hideModal("autoTagLoadingModal");
this.showNotification('No images with linked prompts found in database', 'warning');
return;
}
this.autoTagState.reviewImages = scanData.images;
this.autoTagState.reviewIndex = 0;
this.autoTagState.modelType = modelType;
this.autoTagState.prompt = document.getElementById('autoTagPrompt').value;
this.autoTagState.modelLoaded = false;
this.autoTagState.skipAllTagged = false; // Reset skip flag for new session
document.getElementById('reviewTotalCount').textContent = scanData.images.length;
// Update loading status - model will load on first image
document.getElementById('autoTagLoadingStatus').textContent =
`Found ${scanData.images.length} images. Loading ${modelType.toUpperCase()} model...`;
// Load first image (this will load the model)
await this.loadNextReviewImage();
// Hide loading, show review modal
this.hideModal("autoTagLoadingModal");
this.showModal("autoTagReviewModal");
} catch (error) {
console.error('Error starting review:', error);
this.hideModal("autoTagLoadingModal");
this.showNotification('Failed to start review mode', 'error');
}
}
// Check if tags array has "real" tags (excluding auto-scanned)
getRealTags(tags) {
if (!tags || !Array.isArray(tags)) return [];
return tags.filter(tag => tag !== 'auto-scanned');
}
// Show the re-tag confirmation modal
async showRetagConfirmation(image, imageUrl, realTags) {
return new Promise((resolve) => {
this.autoTagState.retagResolve = resolve;
// Set up the modal content
document.getElementById('retagPreviewImage').src = imageUrl;
document.getElementById('retagImageName').textContent = image.image_path.split('/').pop();
// Display existing tags
const tagsContainer = document.getElementById('retagExistingTags');
tagsContainer.innerHTML = realTags.map(tag =>
`<span class="px-2 py-1 bg-purple-600 text-white text-xs rounded">${this.escapeHtml(tag)}</span>`
).join('');
// Show modal
this.showModal('retagConfirmModal');
});
}
// Handle user's choice in re-tag modal
handleRetagChoice(choice) {
this.hideModal('retagConfirmModal');
if (choice === 'skipAll') {
this.autoTagState.skipAllTagged = true;
}
if (this.autoTagState.retagResolve) {
this.autoTagState.retagResolve(choice);
this.autoTagState.retagResolve = null;
}
}
async loadNextReviewImage() {
if (this.autoTagState.reviewIndex >= this.autoTagState.reviewImages.length) {
this.hideModal("autoTagReviewModal");
this.showNotification('Review complete!', 'success');
this.search();
return;
}
const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex];
document.getElementById('reviewCurrentIndex').textContent = this.autoTagState.reviewIndex + 1;
// Build image URL from image_path (database field)
const imagePath = image.image_path;
const filename = imagePath.split('/').pop();
// Use the serve endpoint with relative path
const relPath = imagePath.includes('/output/') ?
imagePath.substring(imagePath.indexOf('/output/') + 8) : filename;
const imageUrl = `/prompt_manager/images/serve/${relPath}`;
// Check if image already has real tags (excluding auto-scanned)
const realTags = this.getRealTags(image.prompt_tags);
if (realTags.length > 0) {
// Skip if "Skip All Tagged" was selected
if (this.autoTagState.skipAllTagged) {
this.autoTagState.reviewIndex++;
return this.loadNextReviewImage();
}
// Show confirmation modal
const choice = await this.showRetagConfirmation(image, imageUrl, realTags);
if (choice === 'skip' || choice === 'skipAll') {
this.autoTagState.reviewIndex++;
return this.loadNextReviewImage();
}
// choice === 'retag' - continue with tagging
}
document.getElementById('reviewImage').src = imageUrl;
document.getElementById('reviewImageName').textContent = filename || 'Unknown';
document.getElementById('reviewTagsVisible').innerHTML = '<div class="text-gray-400">Generating tags...</div>';
document.getElementById('reviewTagsHidden').innerHTML = '';
document.getElementById('reviewTagsAccordion').classList.add('hidden');
document.getElementById('reviewTagsToggle').classList.add('hidden');
// Store prompt_id from database for applying tags later
this.autoTagState.currentPromptId = image.prompt_id;
this.autoTagState.tagsExpanded = false; // Reset accordion for new image
try {
const response = await fetch('/prompt_manager/autotag/single', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_path: image.image_path,
model_type: this.autoTagState.modelType,
prompt: this.autoTagState.prompt
})
});
const data = await response.json();
if (data.success) {
this.autoTagState.currentTags = data.tags;
// Use prompt_id from database, fallback to API response
this.autoTagState.currentPromptId = image.prompt_id || data.prompt_id;
this.renderReviewTags();
} else {
document.getElementById('reviewTagsVisible').innerHTML =
`<div class="text-red-400">Error: ${data.error}</div>`;
}
} catch (error) {
console.error('Error generating tags:', error);
document.getElementById('reviewTagsVisible').innerHTML =
'<div class="text-red-400">Failed to generate tags</div>';
}
}
renderReviewTags() {
const visibleContainer = document.getElementById('reviewTagsVisible');
const hiddenContainer = document.getElementById('reviewTagsHidden');
const accordion = document.getElementById('reviewTagsAccordion');
const toggleBtn = document.getElementById('reviewTagsToggle');
const toggleIcon = document.getElementById('reviewTagsToggleIcon');
const toggleText = document.getElementById('reviewTagsToggleText');
const countSpan = document.getElementById('reviewTagsCount');
if (this.autoTagState.currentTags.length === 0) {
visibleContainer.innerHTML = '<div class="text-gray-400">No tags generated</div>';
hiddenContainer.innerHTML = '';
accordion.classList.add('hidden');
toggleBtn.classList.add('hidden');
return;
}
// Show first ~10 tags in visible row, rest in accordion
const visibleCount = Math.min(10, this.autoTagState.currentTags.length);
const visibleTags = this.autoTagState.currentTags.slice(0, visibleCount);
const hiddenTags = this.autoTagState.currentTags.slice(visibleCount);
const createTagChip = (tag, index) => `
<span class="tag-chip">
${this.escapeHtml(tag)}
<span class="tag-remove" onclick="window.admin.removeReviewTag(${index})">×</span>
</span>
`;
visibleContainer.innerHTML = visibleTags.map((tag, i) => createTagChip(tag, i)).join('');
if (hiddenTags.length > 0) {
hiddenContainer.innerHTML = hiddenTags.map((tag, i) => createTagChip(tag, visibleCount + i)).join('');
toggleBtn.classList.remove('hidden');
countSpan.textContent = `(${hiddenTags.length} more)`;
// Reset accordion state
if (!this.autoTagState.tagsExpanded) {
accordion.classList.add('hidden');
toggleIcon.textContent = '▼';
toggleText.textContent = 'Show all tags';
}
} else {
hiddenContainer.innerHTML = '';
accordion.classList.add('hidden');
toggleBtn.classList.add('hidden');
}
}
toggleReviewTags() {
const accordion = document.getElementById('reviewTagsAccordion');
const toggleIcon = document.getElementById('reviewTagsToggleIcon');
const toggleText = document.getElementById('reviewTagsToggleText');
this.autoTagState.tagsExpanded = !this.autoTagState.tagsExpanded;
if (this.autoTagState.tagsExpanded) {
accordion.classList.remove('hidden');
toggleIcon.textContent = '▲';
toggleText.textContent = 'Show less';
} else {
accordion.classList.add('hidden');
toggleIcon.textContent = '▼';
toggleText.textContent = 'Show all tags';
}
}
toggleMainTags(promptId) {
const container = document.querySelector(`.tags-accordion[data-prompt-id="${promptId}"]`);
if (!container) return;
const hiddenSection = container.querySelector('.tags-hidden');
const toggleBtn = container.querySelector('.tags-toggle-btn');
if (!hiddenSection || !toggleBtn) return;
const toggleIcon = toggleBtn.querySelector('.toggle-icon');
const toggleText = toggleBtn.querySelector('.toggle-text');
const isHidden = hiddenSection.classList.contains('hidden');
if (isHidden) {
hiddenSection.classList.remove('hidden');
toggleIcon.textContent = '▲';
toggleText.textContent = 'Show less';
} else {
hiddenSection.classList.add('hidden');
toggleIcon.textContent = '▼';
// Recalculate the count from the hidden tags
const hiddenTags = hiddenSection.querySelectorAll('span.inline-flex').length;
toggleText.textContent = `Show ${hiddenTags} more tags`;
}
}
removeReviewTag(index) {
this.autoTagState.currentTags.splice(index, 1);
this.renderReviewTags();
}
skipReviewImage() {
this.autoTagState.reviewIndex++;
this.loadNextReviewImage();
}
async applyReviewTags() {
if (this.autoTagState.currentTags.length === 0) {
this.skipReviewImage();
return;
}
const promptId = this.autoTagState.currentPromptId;
if (!promptId) {
this.showNotification('No linked prompt found for this image', 'warning');
this.skipReviewImage();
return;
}
try {
const response = await fetch('/prompt_manager/autotag/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt_id: promptId,
tags: this.autoTagState.currentTags
})
});
const data = await response.json();
if (data.success) {
this.showNotification(`Applied ${this.autoTagState.currentTags.length} tags`, 'success');
} else {
this.showNotification(data.error || 'Failed to apply tags', 'warning');
}
} catch (error) {
console.error('Error applying tags:', error);
this.showNotification('Failed to apply tags', 'error');
}
this.autoTagState.reviewIndex++;
this.loadNextReviewImage();
}
cancelReview() {
this.autoTagState.reviewImages = [];
this.autoTagState.reviewIndex = 0;
this.autoTagState.currentTags = [];
this.autoTagState.skipAllTagged = false;
this.hideModal("autoTagReviewModal");
}
}
// Initialize the admin interface
const admin = new PromptAdmin();
window.admin = admin;
// Add event listeners for prompt selection
document.addEventListener("change", function (e) {
if (e.target.classList.contains("prompt-checkbox")) {
const promptId = parseInt(e.target.dataset.id);
if (e.target.checked) {
admin.selectedPrompts.add(promptId);
} else {
admin.selectedPrompts.delete(promptId);
}
admin.updateBulkActionButtons();
const allCheckboxes = document.querySelectorAll(".prompt-checkbox");
const checkedCheckboxes = document.querySelectorAll(".prompt-checkbox:checked");
const selectAllCheckbox = document.getElementById("selectAll");
selectAllCheckbox.checked = allCheckboxes.length === checkedCheckboxes.length;
selectAllCheckbox.indeterminate = checkedCheckboxes.length > 0 && checkedCheckboxes.length < allCheckboxes.length;
}
});
// Keyboard shortcuts for modals
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
// Close any open modals
if (document.getElementById('galleryModal') && !document.getElementById('galleryModal').classList.contains('hidden')) {
admin.closeGallery();
} else if (document.getElementById('imageViewerModal') && !document.getElementById('imageViewerModal').classList.contains('hidden')) {
admin.closeImageViewer();
}
} else if (document.getElementById('imageViewerModal') && !document.getElementById('imageViewerModal').classList.contains('hidden')) {
// Handle arrow keys in image viewer
if (e.key === 'ArrowLeft') {
e.preventDefault();
admin.previousImage();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
admin.nextImage();
}
}
});
// Window resize listener for responsive image sizing
window.addEventListener('resize', function() {
// Only apply resize adjustments if image viewer is open and in fit mode
if (document.getElementById('imageViewerModal') && !document.getElementById('imageViewerModal').classList.contains('hidden') && admin.imageViewMode === 'fit') {
admin.applyImageSizing();
}
});
</script>
</body>
</html>