Files
ComfyAssets-ComfyUI_PromptM…/web/admin.html
T

2665 lines
138 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="https://cdn.tailwindcss.com"></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;
}
</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>
<!-- Search Controls -->
<div class="bg-gradient-to-br from-gray-800 to-gray-850 rounded-xl p-6 border border-gray-700 mb-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 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 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="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="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>
</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">
<h2 class="text-xl font-semibold text-gray-100" id="resultsTitle">Recent Prompts</h2>
<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>
</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-md w-full mx-4 border border-gray-700">
<h3 class="text-xl font-semibold text-gray-100 mb-6">⚙️ Settings</h3>
<div class="space-y-6">
<div>
<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 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>
<!-- 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>
</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>
<!-- Image Viewer Modal -->
<div id="imageViewerModal" class="fixed inset-0 bg-black bg-opacity-90 hidden items-center justify-center" style="z-index: 9999;">
<div class="relative w-full h-full flex items-center justify-center">
<!-- Close button -->
<button onclick="window.admin.closeImageViewer()" class="absolute top-4 right-4 p-2 rounded-lg bg-black/50 hover:bg-black/70 transition-colors z-10">
<svg class="w-6 h-6 text-white" 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>
<!-- Previous button -->
<button onclick="window.admin.previousImage()" class="absolute left-4 top-1/2 transform -translate-y-1/2 p-3 rounded-full bg-black/50 hover:bg-black/70 transition-colors z-10">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<!-- Next button -->
<button onclick="window.admin.nextImage()" class="absolute right-4 top-1/2 transform -translate-y-1/2 p-3 rounded-full bg-black/50 hover:bg-black/70 transition-colors z-10">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
<!-- View mode indicator -->
<div id="viewModeIndicator" class="absolute top-4 left-4 text-white text-sm bg-black/50 px-3 py-1 rounded-full z-10">
Fit to Window
</div>
<!-- Image container -->
<div class="w-full h-full flex items-center justify-center p-4">
<img id="viewerImage" src="" alt="Generated image" class="rounded-lg cursor-pointer transition-all duration-300" onclick="window.admin.toggleImageSize()">
</div>
<!-- Image counter -->
<div id="imageCounter" class="absolute bottom-4 left-1/2 transform -translate-x-1/2 text-white text-sm bg-black/50 px-3 py-1 rounded-full z-10">
1 / 1
</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>
<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 };
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("logsBtn").addEventListener("click", () => this.showLogsModal());
// Modals
this.bindModalEvents();
// Auto-search on filter changes
["searchCategory"].forEach((id) => {
document.getElementById(id).addEventListener("change", () => this.search());
});
}
bindModalEvents() {
// Settings modal
document.getElementById("saveSettings").addEventListener("click", () => this.saveSettings());
document.getElementById("cancelSettings").addEventListener("click", () => this.hideModal("settingsModal"));
// Bulk tag modal
document.getElementById("confirmBulkTag").addEventListener("click", () => this.confirmBulkTag());
document.getElementById("cancelBulkTag").addEventListener("click", () => this.hideModal("bulkTagModal"));
// 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());
// Close modals on backdrop click
document.querySelectorAll("[id$='Modal']").forEach((modal) => {
modal.addEventListener("click", (e) => {
if (e.target === modal) {
modal.classList.add("hidden");
}
});
});
}
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';
}
}
} 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() {
try {
const response = await fetch("/prompt_manager/recent?limit=50");
if (response.ok) {
const data = await response.json();
if (data.success) {
this.prompts = data.results;
this.renderPrompts();
// 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");
}
}
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 "-" or if we have more prompts locally than the API count
if (currentTotal === "-" || (this.prompts.length > parseInt(currentTotal) && this.prompts.length > 0)) {
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();
this.updateLocalStats();
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");
}
}
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;
}
container.innerHTML = this.prompts.map((prompt) => this.renderPromptItem(prompt)).join("");
this.selectedPrompts.clear();
this.updateBulkActionButtons();
}
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="flex flex-wrap items-center gap-2">
${tags.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 Tag</span>
</button>
</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) {
console.log(`Rendering stars for prompt ${promptId} with rating ${rating}`);
return Array.from({ length: 5 }, (_, i) => {
const starNum = i + 1;
const isActive = starNum <= rating;
console.log(`Star ${starNum}: ${isActive ? 'active' : 'inactive'}`);
const color = isActive ? '#fbbf24' : '#6b7280'; // yellow-400 : gray-500
return `<button style="font-size: 1.125rem; color: ${color}; background: none; border: none; cursor: pointer; transition: color 0.2s;" onmouseover="this.style.color='#fcd34d'" onmouseout="this.style.color='${color}'" onclick="window.admin.setRating(${promptId}, ${starNum})">⭐</button>`;
}).join("");
}
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
showNotification(message, type = "info") {
const notification = document.createElement("div");
notification.className = `fixed top-4 right-4 px-6 py-4 rounded-lg shadow-lg z-50 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(() => document.body.removeChild(notification), 300);
}, 3000);
}
showModal(modalId) {
document.getElementById(modalId).classList.remove("hidden");
document.getElementById(modalId).classList.add("flex");
}
hideModal(modalId) {
document.getElementById(modalId).classList.add("hidden");
document.getElementById(modalId).classList.remove("flex");
}
showSettingsModal() {
document.getElementById("resultTimeout").value = this.settings.resultTimeout;
document.getElementById("webuiDisplayMode").value = this.settings.webuiDisplayMode;
this.showModal("settingsModal");
}
async saveSettings() {
const timeout = parseInt(document.getElementById("resultTimeout").value);
const displayMode = document.getElementById("webuiDisplayMode").value;
this.settings.resultTimeout = timeout;
this.settings.webuiDisplayMode = displayMode;
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
}),
});
if (response.ok) {
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);
}
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");
}
}
async addTag(promptId) {
const tag = prompt("Enter new tag:");
if (!tag) return;
try {
const response = await fetch(`/prompt_manager/prompts/${promptId}/tags`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag: tag.trim() }),
});
if (response.ok) {
this.showNotification("Tag added", "success");
this.search();
}
} catch (error) {
this.showNotification("Failed to add tag", "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");
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");
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");
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 => `
<div class="group cursor-pointer bg-gray-700 rounded-xl overflow-hidden border border-gray-600 hover:border-gray-500 transition-all duration-200" data-image-url="/prompt_manager/images/${image.id}/file">
<div class="aspect-square bg-gray-800 overflow-hidden relative">
<img src="/prompt_manager/images/${image.id}/file"
alt="Generated image"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
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">
<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;
// Add click event listeners to gallery items
content.querySelectorAll('[data-image-url]').forEach((item, index) => {
item.addEventListener('click', (e) => {
const imageUrl = item.dataset.imageUrl;
console.log("Gallery item clicked, imageUrl:", imageUrl, "index:", index);
this.viewImage(imageUrl, index);
});
});
}
viewImage(imageUrl, imageIndex = 0) {
console.log("viewImage called with:", imageUrl);
const img = document.getElementById("viewerImage");
const modal = document.getElementById("imageViewerModal");
if (!img) {
console.error("viewerImage element not found");
return;
}
if (!modal) {
console.error("imageViewerModal element not found");
return;
}
// Store current gallery images and index
this.currentImageIndex = imageIndex;
this.currentGalleryImages = this.currentGalleryImages || [];
// Reset to fit mode when opening a new image
this.imageViewMode = 'fit';
// Load the image and get its natural dimensions
img.onload = () => {
this.naturalImageSize.width = img.naturalWidth;
this.naturalImageSize.height = img.naturalHeight;
this.applyImageSizing();
};
img.src = imageUrl;
this.updateImageCounter();
this.showModal("imageViewerModal");
console.log("Image viewer modal opened");
}
nextImage() {
if (!this.currentGalleryImages || this.currentGalleryImages.length === 0) return;
this.currentImageIndex = (this.currentImageIndex + 1) % this.currentGalleryImages.length;
const nextImageUrl = `/prompt_manager/images/${this.currentGalleryImages[this.currentImageIndex].id}/file`;
const img = document.getElementById("viewerImage");
// Reset view mode and apply sizing when new image loads
this.imageViewMode = 'fit';
img.onload = () => {
this.naturalImageSize.width = img.naturalWidth;
this.naturalImageSize.height = img.naturalHeight;
this.applyImageSizing();
};
img.src = nextImageUrl;
this.updateImageCounter();
}
previousImage() {
if (!this.currentGalleryImages || this.currentGalleryImages.length === 0) return;
this.currentImageIndex = (this.currentImageIndex - 1 + this.currentGalleryImages.length) % this.currentGalleryImages.length;
const prevImageUrl = `/prompt_manager/images/${this.currentGalleryImages[this.currentImageIndex].id}/file`;
const img = document.getElementById("viewerImage");
// Reset view mode and apply sizing when new image loads
this.imageViewMode = 'fit';
img.onload = () => {
this.naturalImageSize.width = img.naturalWidth;
this.naturalImageSize.height = img.naturalHeight;
this.applyImageSizing();
};
img.src = prevImageUrl;
this.updateImageCounter();
}
updateImageCounter() {
const counter = document.getElementById("imageCounter");
if (counter && this.currentGalleryImages) {
counter.textContent = `${this.currentImageIndex + 1} / ${this.currentGalleryImages.length}`;
}
}
closeGallery() {
this.hideModal("galleryModal");
}
closeImageViewer() {
this.hideModal("imageViewerModal");
document.getElementById("viewerImage").src = "";
this.currentImageIndex = 0;
this.imageViewMode = 'fit';
}
applyImageSizing() {
const img = document.getElementById("viewerImage");
const indicator = document.getElementById("viewModeIndicator");
if (!img || !indicator) return;
if (this.imageViewMode === 'fit') {
// Fit to viewport with padding
img.style.maxWidth = 'calc(100vw - 8rem)';
img.style.maxHeight = 'calc(100vh - 8rem)';
img.style.width = 'auto';
img.style.height = 'auto';
img.style.objectFit = 'contain';
indicator.textContent = 'Fit to Window';
} else {
// Show at full size
img.style.maxWidth = 'none';
img.style.maxHeight = 'none';
img.style.width = this.naturalImageSize.width + 'px';
img.style.height = this.naturalImageSize.height + 'px';
img.style.objectFit = 'none';
indicator.textContent = 'Full Size';
}
}
toggleImageSize() {
this.imageViewMode = this.imageViewMode === 'fit' ? 'full' : 'fit';
this.applyImageSizing();
}
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);
// 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();
}
});
}
// 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];
}
}
// 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').classList.contains('hidden')) {
admin.closeGallery();
} else if (!document.getElementById('imageViewerModal').classList.contains('hidden')) {
admin.closeImageViewer();
}
} else if (!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').classList.contains('hidden') && admin.imageViewMode === 'fit') {
admin.applyImageSizing();
}
});
</script>
</body>
</html>