Spaces:
Sleeping
Sleeping
| // Modern JavaScript Application | |
| class ModelManager { | |
| constructor() { | |
| this.currentCategory = 'pony'; | |
| this.models = {}; | |
| this.editingModel = null; | |
| this.imageCache = new Map(); // Cache for model images | |
| this.availabilityCache = new Map(); // Cache for model availability status | |
| this.isAuthenticated = false; | |
| this.authToken = null; | |
| this.tokenRefreshTimer = null; | |
| this.init(); | |
| } | |
| async init() { | |
| this.initTheme(); | |
| this.bindEvents(); | |
| await this.checkAuthStatus(); | |
| if (this.isAuthenticated) { | |
| this.loadAllModels(); | |
| } else { | |
| // Redirect to login page if not authenticated | |
| window.location.href = '/login'; | |
| } | |
| } | |
| bindEvents() { | |
| // Tab switching | |
| document.querySelectorAll('.tab-button').forEach(button => { | |
| button.addEventListener('click', (e) => { | |
| const category = e.currentTarget.dataset.category; | |
| this.switchCategory(category); | |
| }); | |
| }); | |
| // Add model form | |
| document.getElementById('addModelForm').addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| this.addModel(); | |
| }); | |
| // Browse model button (disabled for now due to Civitai API issues) | |
| document.getElementById('browseModelBtn').addEventListener('click', (e) => { | |
| // Prevent action if disabled | |
| if (e.target.disabled) { | |
| e.preventDefault(); | |
| return; | |
| } | |
| this.showModelBrowser(); | |
| }); | |
| // Refresh button | |
| document.getElementById('refreshBtn').addEventListener('click', () => { | |
| this.loadModels(this.currentCategory); | |
| }); | |
| // Bulk delete button | |
| document.getElementById('bulkDeleteBtn').addEventListener('click', () => { | |
| this.bulkDeleteNoGenModels(); | |
| }); | |
| // Logs refresh button | |
| document.getElementById('refreshLogsBtn').addEventListener('click', () => { | |
| this.loadLogs(); | |
| }); | |
| // Management buttons | |
| document.getElementById('restartBotBtn').addEventListener('click', () => { | |
| this.restartBot(); | |
| }); | |
| document.getElementById('checkStatusBtn').addEventListener('click', () => { | |
| this.checkBotStatus(); | |
| }); | |
| // Configuration buttons | |
| document.getElementById('testBotApiUrl').addEventListener('click', () => { | |
| this.testBotConnection(); | |
| }); | |
| // Log filters | |
| document.getElementById('logFilter').addEventListener('change', () => { | |
| this.filterLogs(); | |
| }); | |
| document.getElementById('categoryLogFilter').addEventListener('change', () => { | |
| this.filterLogs(); | |
| }); | |
| // Activity Logs button in header | |
| document.getElementById('activityLogsBtn').addEventListener('click', () => { | |
| this.showActivityLogs(); | |
| }); | |
| // Bot Management button in header | |
| document.getElementById('botManagementBtn').addEventListener('click', () => { | |
| this.showBotManagement(); | |
| }); | |
| // Model sort filter | |
| document.getElementById('modelSortFilter').addEventListener('change', () => { | |
| this.renderModels(); | |
| }); | |
| // Image filter controls | |
| document.getElementById('sortFilter').addEventListener('change', () => { | |
| this.refreshAllImages(true); | |
| }); | |
| document.getElementById('nsfwFilter').addEventListener('change', () => { | |
| this.refreshAllImages(true); | |
| }); | |
| document.getElementById('periodFilter').addEventListener('change', () => { | |
| this.refreshAllImages(true); | |
| }); | |
| // Modal events | |
| document.getElementById('modalClose').addEventListener('click', () => { | |
| this.closeModal(); | |
| }); | |
| document.getElementById('modalCancel').addEventListener('click', () => { | |
| this.closeModal(); | |
| }); | |
| // Login form events | |
| document.getElementById('loginSubmit').addEventListener('click', async () => { | |
| const adminKey = document.getElementById('adminKey').value; | |
| const loginError = document.getElementById('loginError'); | |
| if (!adminKey) { | |
| loginError.textContent = 'Please enter admin key'; | |
| loginError.style.display = 'block'; | |
| return; | |
| } | |
| const result = await this.login(adminKey); | |
| if (!result.success) { | |
| loginError.textContent = result.message; | |
| loginError.style.display = 'block'; | |
| } else { | |
| loginError.style.display = 'none'; | |
| document.getElementById('adminKey').value = ''; | |
| } | |
| }); | |
| // Handle Enter key in login form | |
| document.getElementById('adminKey').addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') { | |
| document.getElementById('loginSubmit').click(); | |
| } | |
| }); | |
| // Browse modal close events | |
| document.getElementById('browseModalsClose').addEventListener('click', () => { | |
| this.hideBrowseModal(); | |
| }); | |
| // Close browse modal when clicking outside | |
| document.getElementById('browseModelsModal').addEventListener('click', (e) => { | |
| if (e.target === document.getElementById('browseModelsModal')) { | |
| this.hideBrowseModal(); | |
| } | |
| }); | |
| // Browse modal search and controls | |
| document.getElementById('searchModelsBtn').addEventListener('click', () => { | |
| this.searchBrowseModels(); | |
| }); | |
| document.getElementById('modelSearchInput').addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') { | |
| this.searchBrowseModels(); | |
| } | |
| }); | |
| // Login modal close events | |
| document.getElementById('loginModalClose').addEventListener('click', () => { | |
| this.hideLoginModal(); | |
| }); | |
| // Close login modal when clicking outside | |
| document.getElementById('loginModal').addEventListener('click', (e) => { | |
| if (e.target === document.getElementById('loginModal')) { | |
| this.hideLoginModal(); | |
| } | |
| }); | |
| document.getElementById('modalSave').addEventListener('click', () => { | |
| this.saveModelEdit(); | |
| }); | |
| document.getElementById('modalOverlay').addEventListener('click', (e) => { | |
| if (e.target === e.currentTarget) { | |
| this.closeModal(); | |
| } | |
| }); | |
| // Edit form | |
| document.getElementById('editModelForm').addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| this.saveModelEdit(); | |
| }); | |
| // Theme toggle | |
| document.getElementById('themeToggle').addEventListener('click', () => { | |
| this.toggleTheme(); | |
| }); | |
| } | |
| async loadAllModels() { | |
| this.showLoading(true); | |
| try { | |
| const response = await fetch('/api/models'); | |
| if (!response.ok) throw new Error('Failed to load models'); | |
| const data = await response.json(); | |
| this.models = data.models; | |
| // Check availability for all models across all categories | |
| await this.checkAllModelsAvailability(); | |
| this.updateStats(); | |
| this.updateCategoryCounts(); | |
| this.renderModels(); | |
| } catch (error) { | |
| this.showToast('Error loading models', error.message, 'error'); | |
| console.error('Error loading models:', error); | |
| } finally { | |
| this.showLoading(false); | |
| } | |
| } | |
| async checkAllModelsAvailability() { | |
| console.log('🔍 Checking availability for all models...'); | |
| const availabilityPromises = []; | |
| // Iterate through all categories and all models | |
| Object.entries(this.models).forEach(([category, categoryData]) => { | |
| if (categoryData.models) { | |
| Object.entries(categoryData.models).forEach(([modelId, model]) => { | |
| // Create a unique card ID for tracking | |
| const cardId = `${category}-${modelId}`; | |
| const promise = this.checkModelAvailability(cardId, model.urn); | |
| availabilityPromises.push(promise); | |
| }); | |
| } | |
| }); | |
| // Wait for all availability checks to complete | |
| await Promise.allSettled(availabilityPromises); | |
| console.log(`✅ Completed availability checks for ${availabilityPromises.length} models`); | |
| // Update stats and re-render current view if sorting by generation | |
| this.updateStats(); | |
| this.updateCategoryCounts(); | |
| const sortFilter = document.getElementById('modelSortFilter')?.value; | |
| if (sortFilter && sortFilter.includes('generation')) { | |
| this.renderModels(); | |
| } | |
| } | |
| async loadModels(category) { | |
| this.showLoading(true); | |
| try { | |
| const response = await fetch(`/api/models/${category}`); | |
| if (!response.ok) throw new Error('Failed to load models'); | |
| const data = await response.json(); | |
| this.models[category] = { models: data.models }; | |
| this.updateStats(); | |
| this.updateCategoryCounts(); | |
| this.renderModels(); | |
| } catch (error) { | |
| this.showToast('Error loading models', error.message, 'error'); | |
| console.error('Error loading models:', error); | |
| } finally { | |
| this.showLoading(false); | |
| } | |
| } | |
| switchCategory(category) { | |
| this.currentCategory = category; | |
| // Update active tab | |
| document.querySelectorAll('.tab-button').forEach(btn => { | |
| btn.classList.remove('active'); | |
| }); | |
| document.querySelector(`[data-category="${category}"]`).classList.add('active'); | |
| // Show model sections for category tabs (management is now header-only) | |
| document.querySelector('.add-model-section').style.display = 'block'; | |
| document.querySelector('.models-section').style.display = 'block'; | |
| document.getElementById('logsSection').style.display = 'none'; | |
| document.getElementById('managementSection').style.display = 'none'; | |
| // Update current category display | |
| document.getElementById('currentCategory').textContent = | |
| category.charAt(0).toUpperCase() + category.slice(1); | |
| this.renderModels(); | |
| } | |
| showActivityLogs() { | |
| // Hide model sections | |
| document.querySelector('.add-model-section').style.display = 'none'; | |
| document.querySelector('.models-section').style.display = 'none'; | |
| document.getElementById('logsSection').style.display = 'block'; | |
| document.getElementById('managementSection').style.display = 'none'; | |
| // Remove active class from all tabs | |
| document.querySelectorAll('.tab-button').forEach(btn => { | |
| btn.classList.remove('active'); | |
| }); | |
| // Load logs | |
| this.loadLogs(); | |
| } | |
| showBotManagement() { | |
| // Show management section, hide others | |
| document.querySelector('.add-model-section').style.display = 'none'; | |
| document.querySelector('.models-section').style.display = 'none'; | |
| document.getElementById('logsSection').style.display = 'none'; | |
| document.getElementById('managementSection').style.display = 'block'; | |
| // Remove active class from all tabs (but don't try to activate management tab since it doesn't exist) | |
| document.querySelectorAll('.tab-button').forEach(btn => { | |
| btn.classList.remove('active'); | |
| }); | |
| // Initialize configuration | |
| this.initBotApiConfig(); | |
| } | |
| async addModel() { | |
| const form = document.getElementById('addModelForm'); | |
| const formData = new FormData(form); | |
| const modelData = { | |
| displayName: formData.get('displayName'), | |
| urn: formData.get('urn'), | |
| tags: '', | |
| isActive: true, | |
| nsfw: false | |
| }; | |
| this.showLoading(true); | |
| try { | |
| const response = await fetch(`/api/models/${this.currentCategory}`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify(modelData) | |
| }); | |
| if (!response.ok) { | |
| const error = await response.json(); | |
| throw new Error(error.detail || 'Failed to add model'); | |
| } | |
| const result = await response.json(); | |
| this.showToast('Success', result.message, 'success'); | |
| // Log the action | |
| await this.logModelAction('added', this.currentCategory, modelData.displayName, modelData.urn); | |
| // Clear form | |
| form.reset(); | |
| // Reload models | |
| await this.loadModels(this.currentCategory); | |
| } catch (error) { | |
| this.showToast('Error adding model', error.message, 'error'); | |
| console.error('Error adding model:', error); | |
| } finally { | |
| this.showLoading(false); | |
| } | |
| } | |
| renderModels() { | |
| const grid = document.getElementById('modelsGrid'); | |
| const emptyState = document.getElementById('emptyState'); | |
| const categoryModels = this.models[this.currentCategory]?.models || {}; | |
| let modelEntries = Object.entries(categoryModels); | |
| if (modelEntries.length === 0) { | |
| grid.style.display = 'none'; | |
| emptyState.style.display = 'block'; | |
| return; | |
| } | |
| // Apply sorting | |
| modelEntries = this.sortModels(modelEntries); | |
| grid.style.display = 'grid'; | |
| emptyState.style.display = 'none'; | |
| grid.innerHTML = modelEntries.map(([id, model]) => this.createModelCard(id, model)).join(''); | |
| // Load thumbnails and check availability for each model | |
| modelEntries.forEach(([id, model]) => { | |
| this.loadModelImages(id, model.urn); | |
| this.checkModelAvailability(id, model.urn); | |
| }); | |
| // Bind events for action buttons | |
| this.bindModelCardEvents(); | |
| } | |
| sortModels(modelEntries) { | |
| const sortFilter = document.getElementById('modelSortFilter')?.value || 'displayName-asc'; | |
| return modelEntries.sort(([idA, modelA], [idB, modelB]) => { | |
| switch (sortFilter) { | |
| case 'displayName-asc': | |
| return modelA.displayName.localeCompare(modelB.displayName); | |
| case 'displayName-desc': | |
| return modelB.displayName.localeCompare(modelA.displayName); | |
| case 'generation-yes': | |
| // Sort by generation capability, Yes first | |
| const availableA = this.getModelGenerationCapability(modelA); | |
| const availableB = this.getModelGenerationCapability(modelB); | |
| if (availableA === availableB) { | |
| // If same generation capability, sort by name | |
| return modelA.displayName.localeCompare(modelB.displayName); | |
| } | |
| return availableB - availableA; // true (1) comes before false (0) | |
| case 'generation-no': | |
| // Sort by generation capability, No first | |
| const unavailableA = this.getModelGenerationCapability(modelA); | |
| const unavailableB = this.getModelGenerationCapability(modelB); | |
| if (unavailableA === unavailableB) { | |
| // If same generation capability, sort by name | |
| return modelA.displayName.localeCompare(modelB.displayName); | |
| } | |
| return unavailableA - unavailableB; // false (0) comes before true (1) | |
| default: | |
| return modelA.displayName.localeCompare(modelB.displayName); | |
| } | |
| }); | |
| } | |
| getModelGenerationCapability(model) { | |
| const urnData = this.parseUrn(model.urn); | |
| if (urnData && urnData.source === 'civitai') { | |
| const cacheKey = `${urnData.modelId}@${urnData.modelVersionId}`; | |
| const isAvailable = this.availabilityCache.get(cacheKey); | |
| if (isAvailable !== undefined) { | |
| return isAvailable; | |
| } | |
| } | |
| // For non-Civitai models or unknown status, use isActive as fallback | |
| return model.isActive || false; | |
| } | |
| createModelCard(id, model) { | |
| return ` | |
| <div class="model-card" data-model-id="${id}"> | |
| <div class="model-thumbnail-carousel" id="carousel-${id}"> | |
| <div class="carousel-container"> | |
| <button class="carousel-btn carousel-prev" data-model-id="${id}"> | |
| <i class="fas fa-chevron-left"></i> | |
| </button> | |
| <div class="carousel-images" id="images-${id}"> | |
| <div class="thumbnail-placeholder"> | |
| <i class="fas fa-image"></i> | |
| <span>Loading images...</span> | |
| </div> | |
| </div> | |
| <button class="carousel-btn carousel-next" data-model-id="${id}"> | |
| <i class="fas fa-chevron-right"></i> | |
| </button> | |
| <div class="availability-warning" id="availability-${id}" style="display: none;"> | |
| <i class="fas fa-exclamation-triangle" title="Model not available for generation"></i> | |
| </div> | |
| </div> | |
| <div class="carousel-indicators" id="indicators-${id}"></div> | |
| </div> | |
| <div class="model-content"> | |
| <div class="model-header"> | |
| <div> | |
| ${this.createModelTitleLink(model.displayName, model.urn)} | |
| </div> | |
| </div> | |
| <div class="model-urn">${model.urn}</div> | |
| <div class="model-actions"> | |
| <button class="btn btn-sm btn-outline edit-btn" data-model-id="${id}"> | |
| <i class="fas fa-edit"></i> | |
| Edit | |
| </button> | |
| <button class="btn btn-sm btn-danger delete-btn" data-model-id="${id}"> | |
| <i class="fas fa-trash"></i> | |
| Delete | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| bindModelCardEvents() { | |
| // Edit buttons | |
| document.querySelectorAll('.edit-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| const modelId = e.currentTarget.dataset.modelId; | |
| this.editModel(modelId); | |
| }); | |
| }); | |
| // Delete buttons | |
| document.querySelectorAll('.delete-btn').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| const modelId = e.currentTarget.dataset.modelId; | |
| this.deleteModel(modelId); | |
| }); | |
| }); | |
| // Carousel navigation buttons | |
| document.querySelectorAll('.carousel-prev').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const modelId = e.currentTarget.dataset.modelId; | |
| this.navigateCarousel(modelId, -1); | |
| }); | |
| }); | |
| document.querySelectorAll('.carousel-next').forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const modelId = e.currentTarget.dataset.modelId; | |
| this.navigateCarousel(modelId, 1); | |
| }); | |
| }); | |
| } | |
| editModel(modelId) { | |
| const model = this.models[this.currentCategory]?.models[modelId]; | |
| if (!model) return; | |
| this.editingModel = { id: modelId, category: this.currentCategory }; | |
| // Populate form | |
| document.getElementById('editDisplayName').value = model.displayName; | |
| document.getElementById('editUrn').value = model.urn; | |
| this.showModal(); | |
| } | |
| async saveModelEdit() { | |
| if (!this.editingModel) return; | |
| const form = document.getElementById('editModelForm'); | |
| const formData = new FormData(form); | |
| const updates = [ | |
| { field: 'displayName', value: formData.get('displayName') }, | |
| { field: 'urn', value: formData.get('urn') } | |
| ]; | |
| this.showLoading(true); | |
| try { | |
| for (const update of updates) { | |
| const response = await fetch(`/api/models/${this.editingModel.category}/${this.editingModel.id}`, { | |
| method: 'PUT', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify(update) | |
| }); | |
| if (!response.ok) { | |
| const error = await response.json(); | |
| throw new Error(error.detail || 'Failed to update model'); | |
| } | |
| } | |
| this.showToast('Success', 'Model updated successfully', 'success'); | |
| // Log the action | |
| const model = this.models[this.editingModel.category]?.models[this.editingModel.id]; | |
| if (model) { | |
| await this.logModelAction('edited', this.editingModel.category, model.displayName, model.urn); | |
| } | |
| this.closeModal(); | |
| await this.loadModels(this.currentCategory); | |
| } catch (error) { | |
| this.showToast('Error updating model', error.message, 'error'); | |
| console.error('Error updating model:', error); | |
| } finally { | |
| this.showLoading(false); | |
| } | |
| } | |
| async deleteModel(modelId) { | |
| const model = this.models[this.currentCategory]?.models[modelId]; | |
| if (!model) return; | |
| if (!confirm(`Are you sure you want to delete "${model.displayName}"? This action cannot be undone.`)) { | |
| return; | |
| } | |
| this.showLoading(true); | |
| try { | |
| const response = await fetch(`/api/models/${this.currentCategory}/${modelId}`, { | |
| method: 'DELETE' | |
| }); | |
| if (!response.ok) { | |
| const error = await response.json(); | |
| throw new Error(error.detail || 'Failed to delete model'); | |
| } | |
| const result = await response.json(); | |
| this.showToast('Success', result.message, 'success'); | |
| // Log the action | |
| await this.logModelAction('deleted', this.currentCategory, model.displayName, model.urn); | |
| await this.loadModels(this.currentCategory); | |
| } catch (error) { | |
| this.showToast('Error deleting model', error.message, 'error'); | |
| console.error('Error deleting model:', error); | |
| } finally { | |
| this.showLoading(false); | |
| } | |
| } | |
| updateStats() { | |
| let totalModels = 0; | |
| let generationCapableModels = 0; | |
| Object.values(this.models).forEach(category => { | |
| if (category.models) { | |
| Object.entries(category.models).forEach(([modelId, model]) => { | |
| totalModels++; | |
| // Check if model supports generation | |
| const urnData = this.parseUrn(model.urn); | |
| if (urnData && urnData.source === 'civitai') { | |
| const cacheKey = `${urnData.modelId}@${urnData.modelVersionId}`; | |
| const isAvailable = this.availabilityCache.get(cacheKey); | |
| // Count as active if we know it supports generation | |
| if (isAvailable === true) { | |
| generationCapableModels++; | |
| } | |
| } else { | |
| // For non-Civitai models, use the isActive flag as fallback | |
| if (model.isActive) { | |
| generationCapableModels++; | |
| } | |
| } | |
| }); | |
| } | |
| }); | |
| document.getElementById('totalModels').textContent = totalModels; | |
| document.getElementById('activeModels').textContent = generationCapableModels; | |
| } | |
| updateCategoryCounts() { | |
| ['pony', 'illustrious', 'sdxl'].forEach(category => { | |
| const models = this.models[category]?.models || {}; | |
| const totalCount = Object.keys(models).length; | |
| let generationCapableCount = 0; | |
| Object.entries(models).forEach(([modelId, model]) => { | |
| // Check if model supports generation | |
| const urnData = this.parseUrn(model.urn); | |
| if (urnData && urnData.source === 'civitai') { | |
| const cacheKey = `${urnData.modelId}@${urnData.modelVersionId}`; | |
| const isAvailable = this.availabilityCache.get(cacheKey); | |
| if (isAvailable === true) { | |
| generationCapableCount++; | |
| } | |
| } else { | |
| // For non-Civitai models, use the isActive flag as fallback | |
| if (model.isActive) { | |
| generationCapableCount++; | |
| } | |
| } | |
| }); | |
| const badgeText = totalCount > 0 ? `${generationCapableCount}/${totalCount}` : '0'; | |
| document.getElementById(`${category}Count`).textContent = badgeText; | |
| }); | |
| } | |
| showModal() { | |
| document.getElementById('modalOverlay').classList.add('active'); | |
| document.body.style.overflow = 'hidden'; | |
| } | |
| closeModal() { | |
| document.getElementById('modalOverlay').classList.remove('active'); | |
| document.body.style.overflow = ''; | |
| this.editingModel = null; | |
| } | |
| showLoading(show) { | |
| const overlay = document.getElementById('loadingOverlay'); | |
| if (show) { | |
| overlay.classList.add('active'); | |
| } else { | |
| overlay.classList.remove('active'); | |
| } | |
| } | |
| showToast(title, message, type = 'success') { | |
| const container = document.getElementById('toastContainer'); | |
| const toast = document.createElement('div'); | |
| toast.className = `toast ${type}`; | |
| const iconMap = { | |
| success: 'fas fa-check-circle', | |
| error: 'fas fa-exclamation-circle', | |
| warning: 'fas fa-exclamation-triangle' | |
| }; | |
| toast.innerHTML = ` | |
| <i class="${iconMap[type]}"></i> | |
| <div class="toast-content"> | |
| <div class="toast-title">${title}</div> | |
| <div class="toast-message">${message}</div> | |
| </div> | |
| <button class="toast-close"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| `; | |
| container.appendChild(toast); | |
| // Animate in | |
| setTimeout(() => toast.classList.add('show'), 100); | |
| // Bind close button | |
| toast.querySelector('.toast-close').addEventListener('click', () => { | |
| this.removeToast(toast); | |
| }); | |
| // Auto remove after 5 seconds | |
| setTimeout(() => { | |
| if (toast.parentNode) { | |
| this.removeToast(toast); | |
| } | |
| }, 5000); | |
| } | |
| removeToast(toast) { | |
| toast.classList.remove('show'); | |
| setTimeout(() => { | |
| if (toast.parentNode) { | |
| toast.parentNode.removeChild(toast); | |
| } | |
| }, 300); | |
| } | |
| // URN Parsing and Image Loading Functions | |
| createModelTitleLink(displayName, urn) { | |
| const urnData = this.parseUrn(urn); | |
| if (urnData && urnData.source === 'civitai') { | |
| const civitaiUrl = `https://civitai.com/models/${urnData.modelId}?modelVersionId=${urnData.modelVersionId}`; | |
| return `<a href="${civitaiUrl}" target="_blank" class="model-title-link" title="View on Civitai"> | |
| <div class="model-title">${displayName}</div> | |
| <i class="fas fa-external-link-alt model-external-link"></i> | |
| </a>`; | |
| } else { | |
| // Fallback for non-Civitai models | |
| return `<div class="model-title">${displayName}</div>`; | |
| } | |
| } | |
| parseUrn(urn) { | |
| // Parse URN format: urn:air:sdxl:checkpoint:civitai:1465491@1892573 | |
| // Extract modelId and modelVersionId from civitai section | |
| if (!urn.includes('civitai:')) { | |
| return null; | |
| } | |
| const civitaiPart = urn.split('civitai:')[1]; | |
| if (!civitaiPart) return null; | |
| const parts = civitaiPart.split('@'); | |
| const modelId = parts[0]; | |
| const modelVersionId = parts[1]; | |
| return { | |
| modelId: modelId, | |
| modelVersionId: modelVersionId, | |
| source: 'civitai' | |
| }; | |
| } | |
| // URN to URL conversion | |
| urnToUrl(urn) { | |
| const urnData = this.parseUrn(urn); | |
| if (urnData && urnData.source === 'civitai') { | |
| return `https://civitai.com/models/${urnData.modelId}?modelVersionId=${urnData.modelVersionId}`; | |
| } | |
| return null; | |
| } | |
| async loadModelImages(cardId, urn, forceRefresh = false) { | |
| const imagesContainer = document.getElementById(`images-${cardId}`); | |
| const indicatorsContainer = document.getElementById(`indicators-${cardId}`); | |
| if (!imagesContainer || !indicatorsContainer) return; | |
| // Show loading state | |
| imagesContainer.innerHTML = '<div class="loading-spinner">Loading images...</div>'; | |
| indicatorsContainer.innerHTML = ''; | |
| // Get current filter settings | |
| const sortFilter = document.getElementById('sortFilter')?.value || 'Most Reactions'; | |
| const nsfwFilter = document.getElementById('nsfwFilter')?.value || 'None'; | |
| const periodFilter = document.getElementById('periodFilter')?.value || 'AllTime'; | |
| // Create cache key with filters | |
| const cacheKey = `${urn}-${sortFilter}-${nsfwFilter}-${periodFilter}`; | |
| // Check cache first (unless force refresh) | |
| if (!forceRefresh && this.imageCache.has(cacheKey)) { | |
| const cachedImages = this.imageCache.get(cacheKey); | |
| this.displayCarousel(cardId, cachedImages); | |
| return; | |
| } | |
| const urnData = this.parseUrn(urn); | |
| if (!urnData) { | |
| this.displayFallbackCarousel(imagesContainer, 'Invalid URN format'); | |
| return; | |
| } | |
| try { | |
| // Build API URL with dynamic filters | |
| let apiUrl = `https://civitai.com/api/v1/images?modelVersionId=${urnData.modelVersionId}&limit=20`; | |
| // Add sort parameter | |
| const sortParam = encodeURIComponent(sortFilter); | |
| apiUrl += `&sort=${sortParam}`; | |
| // Add period parameter | |
| if (periodFilter !== 'AllTime') { | |
| apiUrl += `&period=${periodFilter}`; | |
| } | |
| // Add NSFW filtering based on level | |
| if (nsfwFilter === 'None') { | |
| apiUrl += '&nsfw=false'; | |
| } else { | |
| apiUrl += '&nsfw=true'; | |
| // Note: Civitai API doesn't support specific NSFW levels in filtering | |
| // We'll filter client-side if needed | |
| } | |
| console.log('Fetching images with filters:', { sort: sortFilter, nsfw: nsfwFilter, period: periodFilter, url: apiUrl }); | |
| // Fetch images from Civitai API | |
| const response = await fetch(apiUrl, | |
| { | |
| method: 'GET', | |
| headers: { | |
| 'Accept': 'application/json', | |
| } | |
| } | |
| ); | |
| if (!response.ok) { | |
| throw new Error(`API response: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| if (data.items && data.items.length > 0) { | |
| // Filter images based on NSFW level | |
| let filteredImages = data.items; | |
| if (nsfwFilter === 'None') { | |
| filteredImages = data.items.filter(img => !img.nsfw); | |
| } else if (nsfwFilter === 'Soft') { | |
| filteredImages = data.items.filter(img => !img.nsfw || img.nsfwLevel <= 1); | |
| } else if (nsfwFilter === 'Mature') { | |
| filteredImages = data.items.filter(img => !img.nsfw || img.nsfwLevel <= 2); | |
| } | |
| // For 'X' level, we include all images (no additional filtering) | |
| if (filteredImages.length === 0) { | |
| const filterMsg = this.getFilterMessage(sortFilter, nsfwFilter, periodFilter); | |
| this.displayFallbackCarousel(imagesContainer, `No results for ${filterMsg}`); | |
| return; | |
| } | |
| const selectedImages = filteredImages.slice(0, 10).map(img => { | |
| // Get the original full-size URL by removing width parameter | |
| const originalUrl = img.url.replace(/\/width=\d+/, ''); | |
| console.log('Original URL:', img.url, '-> Full size:', originalUrl); | |
| console.log('Image metadata:', img.meta); | |
| return { | |
| url: img.url, // For thumbnail display | |
| originalUrl: originalUrl, // For modal display | |
| width: img.width, | |
| height: img.height, | |
| nsfw: img.nsfw, | |
| meta: img.meta || {} // Include metadata from Civitai | |
| }; | |
| }); | |
| // Cache the result with filter-specific key | |
| this.imageCache.set(cacheKey, selectedImages); | |
| this.displayCarousel(cardId, selectedImages); | |
| } else { | |
| const filterMsg = this.getFilterMessage(sortFilter, nsfwFilter, periodFilter); | |
| this.displayFallbackCarousel(imagesContainer, `No results for ${filterMsg}`); | |
| } | |
| } catch (error) { | |
| console.error('Error loading images:', error); | |
| this.displayFallbackCarousel(imagesContainer, 'Failed to load'); | |
| } | |
| } | |
| displayCarousel(cardId, images) { | |
| const imagesContainer = document.getElementById(`images-${cardId}`); | |
| const indicatorsContainer = document.getElementById(`indicators-${cardId}`); | |
| const carouselContainer = document.getElementById(`carousel-${cardId}`); | |
| if (!imagesContainer || !indicatorsContainer) return; | |
| // Store images data on the carousel container | |
| carouselContainer.dataset.images = JSON.stringify(images); | |
| carouselContainer.dataset.currentIndex = '0'; | |
| // Create image elements | |
| imagesContainer.innerHTML = images.map((img, index) => { | |
| console.log(`Creating image ${index}:`, img.url, 'Full:', img.originalUrl, img.width, img.height); // Debug log | |
| return ` | |
| <img src="${img.url}" | |
| alt="Model image ${index + 1}" | |
| class="carousel-image ${index === 0 ? 'active' : ''}" | |
| data-index="${index}" | |
| data-full-url="${img.originalUrl || img.url}" | |
| data-width="${img.width}" | |
| data-height="${img.height}" | |
| onload="this.classList.add('loaded')" | |
| onerror="this.style.display='none'"> | |
| `; | |
| }).join(''); | |
| // Create indicators | |
| if (images.length > 1) { | |
| indicatorsContainer.innerHTML = images.map((_, index) => ` | |
| <button class="carousel-indicator ${index === 0 ? 'active' : ''}" | |
| data-index="${index}" | |
| onclick="modelManager.goToSlide('${cardId}', ${index})"></button> | |
| `).join(''); | |
| indicatorsContainer.style.display = 'flex'; | |
| } else { | |
| indicatorsContainer.style.display = 'none'; | |
| } | |
| // Show/hide navigation buttons | |
| const prevBtn = carouselContainer.querySelector('.carousel-prev'); | |
| const nextBtn = carouselContainer.querySelector('.carousel-next'); | |
| if (images.length > 1) { | |
| prevBtn.style.display = 'flex'; | |
| nextBtn.style.display = 'flex'; | |
| } else { | |
| prevBtn.style.display = 'none'; | |
| nextBtn.style.display = 'none'; | |
| } | |
| // Add click event listener to the images container (event delegation) | |
| imagesContainer.addEventListener('click', (e) => { | |
| if (e.target.classList.contains('carousel-image')) { | |
| // CSS pointer-events should ensure only active images are clickable | |
| const url = e.target.dataset.fullUrl; | |
| const width = parseInt(e.target.dataset.width); | |
| const height = parseInt(e.target.dataset.height); | |
| const index = parseInt(e.target.dataset.index); | |
| // Add bounce animation to clicked thumbnail | |
| const clickedImage = e.target; | |
| clickedImage.style.transform = 'scale(0.9)'; | |
| setTimeout(() => { | |
| clickedImage.style.transform = 'scale(1)'; | |
| }, 100); | |
| console.log('Clicked image index:', index, 'URL:', url, 'Dimensions:', width, 'x', height); | |
| this.openImageModal(url, width, height, cardId, index); | |
| } | |
| }); | |
| } | |
| async refreshAllImages(forceRefresh = false) { | |
| try { | |
| // Get all currently displayed models | |
| const modelCards = document.querySelectorAll('.model-card'); | |
| const refreshPromises = []; | |
| modelCards.forEach(card => { | |
| const cardId = card.id; | |
| const urnElement = card.querySelector('.model-urn'); | |
| if (urnElement) { | |
| const urn = urnElement.textContent.trim(); | |
| refreshPromises.push(this.loadModelImages(cardId, urn, forceRefresh)); | |
| } | |
| }); | |
| // Wait for all images to refresh | |
| await Promise.all(refreshPromises); | |
| } catch (error) { | |
| console.error('Error refreshing images:', error); | |
| } | |
| } | |
| getFilterMessage(sortFilter, nsfwFilter, periodFilter) { | |
| const sortMsg = sortFilter === 'Most Reactions' ? 'most liked' : | |
| sortFilter === 'Most Comments' ? 'most commented' : 'newest'; | |
| const nsfwMsg = nsfwFilter === 'None' ? 'safe content' : | |
| nsfwFilter === 'Soft' ? 'soft content' : | |
| nsfwFilter === 'Mature' ? 'mature content' : 'all content'; | |
| const periodMsg = periodFilter === 'AllTime' ? '' : | |
| periodFilter === 'Year' ? 'this year' : | |
| periodFilter === 'Month' ? 'this month' : | |
| periodFilter === 'Week' ? 'this week' : 'today'; | |
| const timePrefix = periodMsg ? `${periodMsg} ` : ''; | |
| return `${timePrefix}${sortMsg} ${nsfwMsg} filter`; | |
| } | |
| displayFallbackCarousel(container, message) { | |
| container.innerHTML = ` | |
| <div class="thumbnail-fallback"> | |
| <i class="fas fa-image"></i> | |
| <span>${message}</span> | |
| </div> | |
| `; | |
| } | |
| navigateCarousel(cardId, direction) { | |
| const carouselContainer = document.getElementById(`carousel-${cardId}`); | |
| if (!carouselContainer) return; | |
| const images = JSON.parse(carouselContainer.dataset.images || '[]'); | |
| const currentIndex = parseInt(carouselContainer.dataset.currentIndex || '0'); | |
| const newIndex = (currentIndex + direction + images.length) % images.length; | |
| this.goToSlide(cardId, newIndex); | |
| } | |
| goToSlide(cardId, index) { | |
| const carouselContainer = document.getElementById(`carousel-${cardId}`); | |
| const imagesContainer = document.getElementById(`images-${cardId}`); | |
| const indicatorsContainer = document.getElementById(`indicators-${cardId}`); | |
| if (!carouselContainer || !imagesContainer) return; | |
| // Update current index | |
| carouselContainer.dataset.currentIndex = index.toString(); | |
| // Update active image | |
| const imageElements = imagesContainer.querySelectorAll('.carousel-image'); | |
| imageElements.forEach((img, i) => { | |
| img.classList.toggle('active', i === index); | |
| }); | |
| // Update active indicator | |
| const indicatorElements = indicatorsContainer.querySelectorAll('.carousel-indicator'); | |
| indicatorElements.forEach((indicator, i) => { | |
| indicator.classList.toggle('active', i === index); | |
| }); | |
| } | |
| openImageModal(imageUrl, width, height, cardId = null, imageIndex = 0) { | |
| console.log('Opening modal with:', imageUrl, width, height); // Debug log | |
| // Create modal if it doesn't exist | |
| let modal = document.getElementById('imageModal'); | |
| if (!modal) { | |
| modal = document.createElement('div'); | |
| modal.id = 'imageModal'; | |
| modal.className = 'image-modal-overlay'; | |
| modal.innerHTML = ` | |
| <div class="image-modal"> | |
| <button class="image-modal-close"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| <button class="modal-nav modal-prev" id="modalPrev"> | |
| <i class="fas fa-chevron-left"></i> | |
| </button> | |
| <button class="modal-nav modal-next" id="modalNext"> | |
| <i class="fas fa-chevron-right"></i> | |
| </button> | |
| <div class="image-modal-content" id="modalContent"> | |
| <img id="modalImage" src="" alt="Full size image"> | |
| </div> | |
| <div class="image-modal-controls"> | |
| <button class="zoom-btn" id="zoomIn" title="Zoom In"> | |
| <i class="fas fa-search-plus"></i> | |
| </button> | |
| <button class="zoom-btn" id="zoomOut" title="Zoom Out"> | |
| <i class="fas fa-search-minus"></i> | |
| </button> | |
| <button class="zoom-btn" id="zoomReset" title="Reset Zoom"> | |
| <i class="fas fa-expand-arrows-alt"></i> | |
| </button> | |
| </div> | |
| <div class="image-modal-info"> | |
| <span id="imageDimensions"></span> | |
| <span id="imageCounter"></span> | |
| </div> | |
| <div class="image-modal-metadata"> | |
| <button class="metadata-toggle" id="metadataToggle"> | |
| <i class="fas fa-info-circle"></i> | |
| <span>Image Details</span> | |
| <i class="fas fa-chevron-up toggle-icon"></i> | |
| </button> | |
| <div class="metadata-content" id="metadataContent"> | |
| <div class="metadata-grid"> | |
| <div class="metadata-item"> | |
| <span class="metadata-label">Size:</span> | |
| <span class="metadata-value" id="metaSize">-</span> | |
| </div> | |
| <div class="metadata-item"> | |
| <span class="metadata-label">Seed:</span> | |
| <span class="metadata-value" id="metaSeed">-</span> | |
| </div> | |
| <div class="metadata-item"> | |
| <span class="metadata-label">Steps:</span> | |
| <span class="metadata-value" id="metaSteps">-</span> | |
| </div> | |
| <div class="metadata-item"> | |
| <span class="metadata-label">Sampler:</span> | |
| <span class="metadata-value" id="metaSampler">-</span> | |
| </div> | |
| <div class="metadata-item"> | |
| <span class="metadata-label">CFG Scale:</span> | |
| <span class="metadata-value" id="metaCfgScale">-</span> | |
| </div> | |
| <div class="metadata-item"> | |
| <span class="metadata-label">CLIP Skip:</span> | |
| <span class="metadata-value" id="metaClipSkip">-</span> | |
| </div> | |
| </div> | |
| <div class="metadata-prompt"> | |
| <span class="metadata-label">Prompt:</span> | |
| <div class="metadata-value" id="metaPrompt">-</div> | |
| </div> | |
| <div class="metadata-prompt" id="negativePromptSection"> | |
| <span class="metadata-label">Negative Prompt:</span> | |
| <div class="metadata-value" id="metaNegativePrompt">-</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| document.body.appendChild(modal); | |
| this.initializeModalEvents(modal); | |
| } | |
| // Store modal state | |
| modal.dataset.cardId = cardId || ''; | |
| modal.dataset.currentIndex = imageIndex.toString(); | |
| // Show modal with bouncy entrance animation | |
| modal.classList.add('active'); | |
| document.body.style.overflow = 'hidden'; | |
| // Set image and show modal after a small delay for better animation | |
| setTimeout(() => { | |
| this.updateModalImage(imageUrl, width, height); | |
| this.updateModalNavigation(); | |
| this.updateModalMetadata(cardId, imageIndex); | |
| // Ensure image starts at fitted size | |
| this.resetModalZoom(); | |
| }, 50); | |
| } | |
| initializeModalEvents(modal) { | |
| // Initialize zoom state on the modal element | |
| this.initializeModalZoomState(modal); | |
| // Close modal events | |
| modal.addEventListener('click', (e) => { | |
| if (e.target === modal) { | |
| this.closeImageModal(); | |
| } | |
| }); | |
| modal.querySelector('.image-modal-close').addEventListener('click', () => { | |
| this.closeImageModal(); | |
| }); | |
| // Metadata toggle functionality | |
| const metadataToggle = modal.querySelector('#metadataToggle'); | |
| const metadataContent = modal.querySelector('#metadataContent'); | |
| metadataToggle.addEventListener('click', () => { | |
| const isExpanded = metadataContent.classList.contains('expanded'); | |
| if (isExpanded) { | |
| metadataContent.classList.remove('expanded'); | |
| metadataToggle.classList.remove('expanded'); | |
| } else { | |
| metadataContent.classList.add('expanded'); | |
| metadataToggle.classList.add('expanded'); | |
| } | |
| }); | |
| // Navigation events | |
| document.getElementById('modalPrev').addEventListener('click', () => { | |
| this.navigateModalImage(-1); | |
| }); | |
| document.getElementById('modalNext').addEventListener('click', () => { | |
| this.navigateModalImage(1); | |
| }); | |
| // Zoom events | |
| const modalImage = document.getElementById('modalImage'); | |
| const modalContent = document.getElementById('modalContent'); | |
| document.getElementById('zoomIn').addEventListener('click', () => { | |
| modal.zoomLevel = Math.min(modal.zoomLevel * 1.5, 5); | |
| this.applyZoom(modalImage, modal.zoomLevel, modal.currentX, modal.currentY); | |
| }); | |
| document.getElementById('zoomOut').addEventListener('click', () => { | |
| modal.zoomLevel = Math.max(modal.zoomLevel / 1.5, 0.5); | |
| this.applyZoom(modalImage, modal.zoomLevel, modal.currentX, modal.currentY); | |
| }); | |
| document.getElementById('zoomReset').addEventListener('click', () => { | |
| this.resetModalZoom(); | |
| }); | |
| // Double-click to zoom | |
| modalImage.addEventListener('dblclick', () => { | |
| if (modal.zoomLevel === 1) { | |
| modal.zoomLevel = 2; | |
| this.applyZoom(modalImage, modal.zoomLevel, modal.currentX, modal.currentY); | |
| } else { | |
| this.resetModalZoom(); | |
| } | |
| }); | |
| // Drag to pan when zoomed | |
| modalImage.addEventListener('mousedown', (e) => { | |
| if (modal.zoomLevel > 1) { | |
| modal.isDragging = true; | |
| modal.startX = e.clientX - modal.currentX; | |
| modal.startY = e.clientY - modal.currentY; | |
| modalImage.style.cursor = 'grabbing'; | |
| } | |
| }); | |
| document.addEventListener('mousemove', (e) => { | |
| if (modal.isDragging && modal.zoomLevel > 1) { | |
| modal.currentX = e.clientX - modal.startX; | |
| modal.currentY = e.clientY - modal.startY; | |
| this.applyZoom(modalImage, modal.zoomLevel, modal.currentX, modal.currentY); | |
| } | |
| }); | |
| document.addEventListener('mouseup', () => { | |
| if (modal.isDragging) { | |
| modal.isDragging = false; | |
| modalImage.style.cursor = modal.zoomLevel > 1 ? 'grab' : 'pointer'; | |
| } | |
| }); | |
| // Keyboard navigation | |
| document.addEventListener('keydown', (e) => { | |
| if (modal.classList.contains('active')) { | |
| switch(e.key) { | |
| case 'Escape': | |
| this.closeImageModal(); | |
| break; | |
| case 'ArrowLeft': | |
| this.navigateModalImage(-1); | |
| break; | |
| case 'ArrowRight': | |
| this.navigateModalImage(1); | |
| break; | |
| case '+': | |
| case '=': | |
| document.getElementById('zoomIn').click(); | |
| break; | |
| case '-': | |
| document.getElementById('zoomOut').click(); | |
| break; | |
| case '0': | |
| document.getElementById('zoomReset').click(); | |
| break; | |
| } | |
| } | |
| }); | |
| } | |
| applyZoom(image, zoom, x, y) { | |
| image.style.transform = `scale(${zoom}) translate(${x/zoom}px, ${y/zoom}px)`; | |
| image.style.cursor = zoom > 1 ? 'grab' : 'pointer'; | |
| } | |
| updateModalImage(imageUrl, width, height, direction = 0) { | |
| const modalImage = document.getElementById('modalImage'); | |
| const imageDimensions = document.getElementById('imageDimensions'); | |
| // Enhanced bouncy slide-out animation based on direction | |
| modalImage.style.opacity = '0'; | |
| const slideDirection = direction > 0 ? '80px' : direction < 0 ? '-80px' : '0px'; | |
| const scaleDirection = direction !== 0 ? '0.85' : '0.9'; | |
| modalImage.style.transform = `scale(${scaleDirection}) translateX(${slideDirection}) rotateY(${direction * 5}deg)`; | |
| // Update dimensions immediately | |
| imageDimensions.textContent = `${width} × ${height}px`; | |
| // Preload the image to avoid flicker | |
| const tempImage = new Image(); | |
| tempImage.onload = () => { | |
| // Clear any existing styles that might interfere | |
| modalImage.style.width = ''; | |
| modalImage.style.height = ''; | |
| // Set the source | |
| modalImage.src = imageUrl; | |
| // Apply proper sizing immediately | |
| this.ensureImageFitted(tempImage, modalImage); | |
| // Enhanced bouncy slide-in animation with multiple stages | |
| setTimeout(() => { | |
| modalImage.style.opacity = '1'; | |
| modalImage.style.transform = 'scale(1.05) translateX(0px) rotateY(0deg)'; | |
| // Second bounce stage for extra bounciness | |
| setTimeout(() => { | |
| modalImage.style.transform = 'scale(1) translateX(0px) rotateY(0deg)'; | |
| }, 200); | |
| }, direction !== 0 ? 100 : 50); | |
| }; | |
| tempImage.onerror = () => { | |
| // Fallback if image fails to load with gentle animation | |
| modalImage.src = imageUrl; | |
| modalImage.style.opacity = '1'; | |
| modalImage.style.transform = 'scale(1) translateX(0px) rotateY(0deg)'; | |
| }; | |
| // Start preloading | |
| tempImage.src = imageUrl; | |
| } | |
| updateModalMetadata(cardId, imageIndex) { | |
| // Get images from the carousel container where they're actually stored | |
| const carouselContainer = document.getElementById(`carousel-${cardId}`); | |
| if (!carouselContainer || !carouselContainer.dataset.images) { | |
| console.log('No carousel container or images data found for metadata:', cardId); | |
| this.clearModalMetadata(); | |
| return; | |
| } | |
| const images = JSON.parse(carouselContainer.dataset.images || '[]'); | |
| if (!images[imageIndex]) { | |
| console.log('No image found at index:', imageIndex, 'in images:', images); | |
| this.clearModalMetadata(); | |
| return; | |
| } | |
| const imageData = images[imageIndex]; | |
| console.log('Image data for metadata:', imageData); | |
| console.log('Image meta property:', imageData.meta); | |
| const meta = imageData.meta || {}; | |
| console.log('Processed meta object:', meta); | |
| // Update metadata values | |
| document.getElementById('metaSize').textContent = meta.Size || '-'; | |
| document.getElementById('metaSeed').textContent = meta.seed || '-'; | |
| document.getElementById('metaSteps').textContent = meta.steps || '-'; | |
| document.getElementById('metaSampler').textContent = meta.sampler || '-'; | |
| document.getElementById('metaCfgScale').textContent = meta.cfgScale || '-'; | |
| document.getElementById('metaClipSkip').textContent = meta.clipSkip || '-'; | |
| // Update prompt (handle multiline text) | |
| const promptElement = document.getElementById('metaPrompt'); | |
| promptElement.textContent = meta.prompt || '-'; | |
| // Update negative prompt and show/hide section | |
| const negativePromptElement = document.getElementById('metaNegativePrompt'); | |
| const negativePromptSection = document.getElementById('negativePromptSection'); | |
| if (meta.negativePrompt) { | |
| negativePromptElement.textContent = meta.negativePrompt; | |
| negativePromptSection.style.display = 'block'; | |
| } else { | |
| negativePromptSection.style.display = 'none'; | |
| } | |
| } | |
| clearModalMetadata() { | |
| // Clear all metadata values | |
| const metaElements = [ | |
| 'metaSize', 'metaSeed', 'metaSteps', 'metaSampler', | |
| 'metaCfgScale', 'metaClipSkip', 'metaPrompt', 'metaNegativePrompt' | |
| ]; | |
| metaElements.forEach(id => { | |
| const element = document.getElementById(id); | |
| if (element) element.textContent = '-'; | |
| }); | |
| // Hide negative prompt section | |
| document.getElementById('negativePromptSection').style.display = 'none'; | |
| } | |
| initializeModalZoomState(modal) { | |
| modal.zoomLevel = 1; | |
| modal.isDragging = false; | |
| modal.currentX = 0; | |
| modal.currentY = 0; | |
| modal.startX = 0; | |
| modal.startY = 0; | |
| } | |
| ensureImageFitted(sourceImage, targetImage = null) { | |
| const modalImage = targetImage || sourceImage; | |
| const modalContent = modalImage.parentElement; | |
| if (!modalContent) return; | |
| const containerWidth = modalContent.clientWidth; | |
| const containerHeight = modalContent.clientHeight; | |
| const imageWidth = sourceImage.naturalWidth || sourceImage.width; | |
| const imageHeight = sourceImage.naturalHeight || sourceImage.height; | |
| console.log('Container:', containerWidth, 'x', containerHeight); // Debug log | |
| console.log('Image natural size:', imageWidth, 'x', imageHeight); // Debug log | |
| // Calculate scale to fit entire image within container | |
| const scaleX = containerWidth / imageWidth; | |
| const scaleY = containerHeight / imageHeight; | |
| const scale = Math.min(scaleX, scaleY, 1); // Don't scale up, only down | |
| console.log('Calculated scale:', scale); // Debug log | |
| // Apply the fitted size | |
| modalImage.style.width = `${imageWidth * scale}px`; | |
| modalImage.style.height = `${imageHeight * scale}px`; | |
| modalImage.style.transform = 'scale(1) translate(0px, 0px)'; | |
| } | |
| resetModalZoom() { | |
| const modal = document.getElementById('imageModal'); | |
| const modalImage = document.getElementById('modalImage'); | |
| if (modal && modalImage) { | |
| // Reset zoom state | |
| modal.zoomLevel = 1; | |
| modal.currentX = 0; | |
| modal.currentY = 0; | |
| modal.isDragging = false; | |
| // Clear any transforms and let CSS handle fitting | |
| modalImage.style.transform = 'scale(1) translate(0px, 0px)'; | |
| modalImage.style.cursor = 'pointer'; | |
| // Ensure proper fitting | |
| if (modalImage.complete && modalImage.naturalWidth > 0) { | |
| this.ensureImageFitted(modalImage); | |
| } | |
| console.log('Modal zoom reset to fitted size'); // Debug log | |
| } | |
| } | |
| updateModalNavigation() { | |
| const modal = document.getElementById('imageModal'); | |
| const cardId = modal.dataset.cardId; | |
| const currentIndex = parseInt(modal.dataset.currentIndex || '0'); | |
| const prevBtn = document.getElementById('modalPrev'); | |
| const nextBtn = document.getElementById('modalNext'); | |
| const counter = document.getElementById('imageCounter'); | |
| if (cardId) { | |
| const carouselContainer = document.getElementById(`carousel-${cardId}`); | |
| if (carouselContainer) { | |
| const images = JSON.parse(carouselContainer.dataset.images || '[]'); | |
| const totalImages = images.length; | |
| // Show/hide navigation buttons | |
| prevBtn.style.display = totalImages > 1 ? 'flex' : 'none'; | |
| nextBtn.style.display = totalImages > 1 ? 'flex' : 'none'; | |
| // Update counter | |
| counter.textContent = totalImages > 1 ? `${currentIndex + 1} / ${totalImages}` : ''; | |
| return; | |
| } | |
| } | |
| // Hide navigation if no carousel context | |
| prevBtn.style.display = 'none'; | |
| nextBtn.style.display = 'none'; | |
| counter.textContent = ''; | |
| } | |
| navigateModalImage(direction) { | |
| const modal = document.getElementById('imageModal'); | |
| const cardId = modal.dataset.cardId; | |
| const currentIndex = parseInt(modal.dataset.currentIndex || '0'); | |
| if (!cardId) return; | |
| const carouselContainer = document.getElementById(`carousel-${cardId}`); | |
| if (!carouselContainer) return; | |
| const images = JSON.parse(carouselContainer.dataset.images || '[]'); | |
| const newIndex = (currentIndex + direction + images.length) % images.length; | |
| const newImage = images[newIndex]; | |
| if (newImage) { | |
| modal.dataset.currentIndex = newIndex.toString(); | |
| this.updateModalImage(newImage.originalUrl || newImage.url, newImage.width, newImage.height, direction); | |
| this.updateModalNavigation(); | |
| this.updateModalMetadata(cardId, newIndex); | |
| // Reset zoom and position when navigating to new image | |
| this.resetModalZoom(); | |
| } | |
| } | |
| closeImageModal() { | |
| const modal = document.getElementById('imageModal'); | |
| if (modal) { | |
| modal.classList.remove('active'); | |
| document.body.style.overflow = ''; | |
| } | |
| } | |
| // Model Availability Checking | |
| async checkModelAvailability(cardId, urn) { | |
| const urnData = this.parseUrn(urn); | |
| if (!urnData || urnData.source !== 'civitai') { | |
| return; // Only check Civitai models | |
| } | |
| const cacheKey = `${urnData.modelId}@${urnData.modelVersionId}`; | |
| // Check cache first | |
| if (this.availabilityCache.has(cacheKey)) { | |
| const isAvailable = this.availabilityCache.get(cacheKey); | |
| this.updateAvailabilityDisplay(cardId, isAvailable); | |
| return; | |
| } | |
| try { | |
| const apiUrl = `https://civitai.com/api/v1/models/${urnData.modelId}?modelVersions.id=${urnData.modelVersionId}`; | |
| const response = await fetch(apiUrl, { | |
| method: 'GET', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| } | |
| }); | |
| if (!response.ok) { | |
| console.warn(`Failed to check availability for model ${urnData.modelId}: ${response.status}`); | |
| return; | |
| } | |
| const data = await response.json(); | |
| // Check if the specific model version exists in the response | |
| const modelVersion = data.modelVersions?.find(v => v.id.toString() === urnData.modelVersionId); | |
| if (modelVersion) { | |
| // supportsGeneration is at the model level, not version level | |
| const isAvailable = data.supportsGeneration === true; | |
| // Cache the result | |
| this.availabilityCache.set(cacheKey, isAvailable); | |
| // Update display if the element exists (model is currently rendered) | |
| this.updateAvailabilityDisplay(cardId, isAvailable); | |
| console.log(`Model ${urnData.modelId}@${urnData.modelVersionId} availability: ${isAvailable} (model-level supportsGeneration: ${data.supportsGeneration})`); | |
| } else { | |
| console.warn(`Model version ${urnData.modelVersionId} not found in API response`); | |
| } | |
| } catch (error) { | |
| console.error('Error checking model availability:', error); | |
| } | |
| } | |
| updateAvailabilityDisplay(cardId, isAvailable) { | |
| const warningElement = document.getElementById(`availability-${cardId}`); | |
| if (warningElement) { | |
| if (isAvailable === false) { | |
| warningElement.style.display = 'flex'; | |
| } else { | |
| warningElement.style.display = 'none'; | |
| } | |
| // Only update stats when UI elements exist (to avoid too many updates during background checks) | |
| this.updateStats(); | |
| this.updateCategoryCounts(); | |
| } | |
| // If no UI element exists, this is a background check, stats will be updated after all checks complete | |
| } | |
| // Bulk Delete No-Generation Models | |
| async bulkDeleteNoGenModels() { | |
| const confirmMessage = `⚠️ WARNING: This will permanently delete ALL models that don't support generation across ALL categories.\n\nThis action cannot be undone. Are you absolutely sure?`; | |
| if (!confirm(confirmMessage)) { | |
| return; | |
| } | |
| const secondConfirm = 'Type "DELETE" to confirm bulk deletion:'; | |
| const userInput = prompt(secondConfirm); | |
| if (userInput !== 'DELETE') { | |
| this.showToast('Cancelled', 'Bulk deletion cancelled', 'warning'); | |
| return; | |
| } | |
| this.showLoading(true); | |
| let deletedCount = 0; | |
| const deletedModels = []; | |
| try { | |
| // Check all categories for models without generation support | |
| for (const category of ['pony', 'illustrious', 'sdxl']) { | |
| const categoryModels = this.models[category]?.models || {}; | |
| for (const [modelId, model] of Object.entries(categoryModels)) { | |
| const urnData = this.parseUrn(model.urn); | |
| if (urnData && urnData.source === 'civitai') { | |
| const cacheKey = `${urnData.modelId}@${urnData.modelVersionId}`; | |
| // Check if model is cached as unavailable | |
| if (this.availabilityCache.has(cacheKey)) { | |
| const isAvailable = this.availabilityCache.get(cacheKey); | |
| if (isAvailable === false) { | |
| try { | |
| const response = await fetch(`/api/models/${category}/${modelId}`, { | |
| method: 'DELETE' | |
| }); | |
| if (response.ok) { | |
| deletedCount++; | |
| deletedModels.push({ | |
| category, | |
| displayName: model.displayName, | |
| urn: model.urn | |
| }); | |
| // Log the deletion | |
| await this.logModelAction('bulk_deleted', category, model.displayName, model.urn); | |
| } | |
| } catch (error) { | |
| console.error(`Failed to delete model ${modelId}:`, error); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| if (deletedCount > 0) { | |
| this.showToast('Success', `Bulk deleted ${deletedCount} models that don't support generation`, 'success'); | |
| // Reload all models | |
| await this.loadAllModels(); | |
| } else { | |
| this.showToast('Info', 'No models found that lack generation support', 'warning'); | |
| } | |
| } catch (error) { | |
| this.showToast('Error', `Bulk deletion failed: ${error.message}`, 'error'); | |
| console.error('Bulk deletion error:', error); | |
| } finally { | |
| this.showLoading(false); | |
| } | |
| } | |
| // Logging Functions | |
| async logModelAction(action, category, displayName, urn) { | |
| console.log(`📝 Logging action: ${action} - ${category} - ${displayName}`); | |
| try { | |
| const logEntry = { | |
| timestamp: new Date().toISOString(), | |
| action: action, | |
| category: category, | |
| displayName: displayName, | |
| urn: urn | |
| }; | |
| console.log('📝 Sending log entry:', logEntry); | |
| const response = await fetch('/api/logs', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify(logEntry) | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| console.error(`❌ Failed to log model action: ${response.status} - ${errorText}`); | |
| this.showToast('Warning', `Failed to log action: ${response.status}`, 'warning'); | |
| } else { | |
| const result = await response.json(); | |
| console.log('✅ Successfully logged action:', result); | |
| } | |
| } catch (error) { | |
| console.error('💥 Error logging model action:', error); | |
| this.showToast('Warning', `Logging error: ${error.message}`, 'warning'); | |
| } | |
| } | |
| async loadLogs() { | |
| console.log('📖 Loading logs...'); | |
| this.showLoading(true); | |
| try { | |
| console.log('📖 Fetching logs from /api/logs'); | |
| const response = await fetch('/api/logs'); | |
| console.log('📖 Response status:', response.status); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| console.error('📖 Failed to load logs:', response.status, errorText); | |
| throw new Error(`Failed to load logs: ${response.status} - ${errorText}`); | |
| } | |
| const data = await response.json(); | |
| console.log('📖 Received logs data:', data); | |
| console.log('📖 Number of logs:', data.logs ? data.logs.length : 0); | |
| this.renderLogs(data.logs || []); | |
| } catch (error) { | |
| console.error('💥 Error loading logs:', error); | |
| this.showToast('Error loading logs', error.message, 'error'); | |
| } finally { | |
| this.showLoading(false); | |
| } | |
| } | |
| renderLogs(logs) { | |
| const tableBody = document.getElementById('logsTableBody'); | |
| const emptyState = document.getElementById('emptyLogsState'); | |
| const table = document.getElementById('logsTable'); | |
| if (logs.length === 0) { | |
| table.style.display = 'none'; | |
| emptyState.style.display = 'block'; | |
| return; | |
| } | |
| table.style.display = 'table'; | |
| emptyState.style.display = 'none'; | |
| // Sort logs by timestamp (newest first) | |
| const sortedLogs = logs.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); | |
| tableBody.innerHTML = sortedLogs.map(log => { | |
| const timestamp = new Date(log.timestamp).toLocaleString(); | |
| const url = this.urnToUrl(log.urn); | |
| const urlCell = url | |
| ? `<a href="${url}" target="_blank" class="log-url"><i class="fas fa-external-link-alt"></i> View on Civitai</a>` | |
| : `<span class="log-url disabled">N/A</span>`; | |
| return ` | |
| <tr class="log-entry" data-action="${log.action}" data-category="${log.category}"> | |
| <td class="log-timestamp">${timestamp}</td> | |
| <td><span class="log-action ${log.action}">${log.action.replace('_', ' ')}</span></td> | |
| <td><span class="log-category">${log.category.toUpperCase()}</span></td> | |
| <td>${log.displayName}</td> | |
| <td class="log-urn">${log.urn}</td> | |
| <td>${urlCell}</td> | |
| </tr> | |
| `; | |
| }).join(''); | |
| // Store all logs for filtering | |
| this.allLogs = logs; | |
| } | |
| filterLogs() { | |
| if (!this.allLogs) return; | |
| const actionFilter = document.getElementById('logFilter').value; | |
| const categoryFilter = document.getElementById('categoryLogFilter').value; | |
| let filteredLogs = this.allLogs; | |
| if (actionFilter !== 'all') { | |
| filteredLogs = filteredLogs.filter(log => log.action === actionFilter); | |
| } | |
| if (categoryFilter !== 'all') { | |
| filteredLogs = filteredLogs.filter(log => log.category === categoryFilter); | |
| } | |
| this.renderLogs(filteredLogs); | |
| } | |
| // Theme Management | |
| initTheme() { | |
| // Check for saved theme preference or default to light mode | |
| const savedTheme = localStorage.getItem('theme') || 'light'; | |
| this.setTheme(savedTheme); | |
| } | |
| toggleTheme() { | |
| const currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; | |
| const newTheme = currentTheme === 'light' ? 'dark' : 'light'; | |
| this.setTheme(newTheme); | |
| } | |
| setTheme(theme) { | |
| document.documentElement.setAttribute('data-theme', theme); | |
| localStorage.setItem('theme', theme); | |
| // Update toggle button icon | |
| const themeIcon = document.getElementById('themeIcon'); | |
| if (theme === 'dark') { | |
| themeIcon.className = 'fas fa-sun'; | |
| themeIcon.parentElement.title = 'Switch to light mode'; | |
| } else { | |
| themeIcon.className = 'fas fa-moon'; | |
| themeIcon.parentElement.title = 'Switch to dark mode'; | |
| } | |
| } | |
| // Authentication Methods | |
| async checkAuthStatus() { | |
| try { | |
| // First check localStorage for token | |
| const token = localStorage.getItem('auth_token'); | |
| const expires = localStorage.getItem('token_expires'); | |
| let headers = {}; | |
| if (token && expires && Date.now() < parseInt(expires)) { | |
| headers['Authorization'] = `Bearer ${token}`; | |
| this.authToken = token; | |
| console.log('Using localStorage token for auth check'); | |
| } | |
| const response = await fetch('/api/auth-status', { headers }); | |
| const data = await response.json(); | |
| this.isAuthenticated = data.authenticated; | |
| if (this.isAuthenticated && token) { | |
| // Set up token refresh timer | |
| this.setupTokenRefresh(parseInt(expires)); | |
| } else if (!this.isAuthenticated) { | |
| // Clean up invalid token | |
| this.clearAuthData(); | |
| } | |
| } catch (error) { | |
| console.error('Auth check failed:', error); | |
| this.isAuthenticated = false; | |
| this.clearAuthData(); | |
| } | |
| } | |
| async login(adminKey) { | |
| try { | |
| const response = await fetch('/api/login', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ admin_key: adminKey }) | |
| }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| // Store token in localStorage | |
| if (data.token) { | |
| localStorage.setItem('auth_token', data.token); | |
| localStorage.setItem('token_expires', Date.now() + (data.expires_in * 1000)); | |
| this.authToken = data.token; | |
| // Set up token refresh | |
| this.setupTokenRefresh(Date.now() + (data.expires_in * 1000)); | |
| console.log('Token stored and refresh timer set'); | |
| } | |
| this.isAuthenticated = true; | |
| this.hideAuthOverlay(); | |
| this.hideLoginModal(); | |
| this.loadAllModels(); | |
| return { success: true }; | |
| } else { | |
| const error = await response.json(); | |
| return { success: false, message: error.detail || 'Login failed' }; | |
| } | |
| } catch (error) { | |
| console.error('Login error:', error); | |
| return { success: false, message: 'Network error' }; | |
| } | |
| } | |
| async logout() { | |
| try { | |
| // Send logout request with token if available | |
| const headers = {}; | |
| if (this.authToken) { | |
| headers['Authorization'] = `Bearer ${this.authToken}`; | |
| } | |
| await fetch('/api/logout', { | |
| method: 'POST', | |
| headers | |
| }); | |
| this.clearAuthData(); | |
| this.isAuthenticated = false; | |
| // Redirect to login page | |
| window.location.href = '/login'; | |
| } catch (error) { | |
| console.error('Logout error:', error); | |
| // Still clear local data even if server request fails | |
| this.clearAuthData(); | |
| window.location.href = '/login'; | |
| } | |
| } | |
| showLoginModal() { | |
| document.getElementById('loginModal').classList.add('active'); | |
| // Clear any previous errors | |
| document.getElementById('loginError').style.display = 'none'; | |
| document.getElementById('adminKey').value = ''; | |
| // Auto-focus the admin key input | |
| setTimeout(() => { | |
| document.getElementById('adminKey').focus(); | |
| }, 100); | |
| } | |
| hideLoginModal() { | |
| document.getElementById('loginModal').classList.remove('active'); | |
| } | |
| clearAuthData() { | |
| localStorage.removeItem('auth_token'); | |
| localStorage.removeItem('token_expires'); | |
| this.authToken = null; | |
| if (this.tokenRefreshTimer) { | |
| clearTimeout(this.tokenRefreshTimer); | |
| this.tokenRefreshTimer = null; | |
| } | |
| } | |
| setupTokenRefresh(expiresAt) { | |
| // Clear existing timer | |
| if (this.tokenRefreshTimer) { | |
| clearTimeout(this.tokenRefreshTimer); | |
| } | |
| // Calculate when to refresh (30 minutes before expiry) | |
| const refreshTime = expiresAt - Date.now() - (30 * 60 * 1000); | |
| if (refreshTime > 0) { | |
| console.log(`Setting up token refresh in ${Math.round(refreshTime / 1000 / 60)} minutes`); | |
| this.tokenRefreshTimer = setTimeout(() => { | |
| this.refreshTokenIfNeeded(); | |
| }, refreshTime); | |
| } | |
| } | |
| async refreshTokenIfNeeded() { | |
| const expires = localStorage.getItem('token_expires'); | |
| if (!expires) return; | |
| const timeUntilExpiry = parseInt(expires) - Date.now(); | |
| const oneHour = 60 * 60 * 1000; | |
| // Refresh if less than 1 hour remaining | |
| if (timeUntilExpiry < oneHour) { | |
| console.log('Token expiring soon, showing login modal for refresh'); | |
| this.showLoginModal(); | |
| } else { | |
| // Set up next refresh check | |
| this.setupTokenRefresh(parseInt(expires)); | |
| } | |
| } | |
| async makeAuthenticatedRequest(url, options = {}) { | |
| // Add auth headers to requests | |
| const headers = { | |
| 'Content-Type': 'application/json', | |
| ...options.headers | |
| }; | |
| // Add token if available | |
| const token = this.authToken || localStorage.getItem('auth_token'); | |
| if (token) { | |
| headers['Authorization'] = `Bearer ${token}`; | |
| } | |
| const response = await fetch(url, { | |
| ...options, | |
| headers | |
| }); | |
| // Handle token expiry | |
| if (response.status === 401) { | |
| console.log('Token expired, clearing auth data'); | |
| this.clearAuthData(); | |
| this.isAuthenticated = false; | |
| window.location.href = '/login'; | |
| throw new Error('Authentication expired'); | |
| } | |
| return response; | |
| } | |
| showModelBrowser() { | |
| console.log('Opening model browser...'); | |
| document.getElementById('browseModelsModal').classList.add('active'); | |
| this.loadBrowseModels(); | |
| } | |
| hideBrowseModal() { | |
| document.getElementById('browseModelsModal').classList.remove('active'); | |
| } | |
| async loadBrowseModels(query = '', sort = 'Newest', nsfw = 'None', page = 1) { | |
| const loadingEl = document.getElementById('browseLoading'); | |
| const gridEl = document.getElementById('browseModelsGrid'); | |
| const emptyStateEl = document.getElementById('emptyBrowseState'); | |
| const paginationEl = document.getElementById('browsePagination'); | |
| // Show loading | |
| loadingEl.style.display = 'flex'; | |
| gridEl.innerHTML = ''; | |
| emptyStateEl.style.display = 'none'; | |
| paginationEl.style.display = 'none'; | |
| try { | |
| // Build API URL with parameters | |
| const params = new URLSearchParams({ | |
| supportsGeneration: 'true', | |
| limit: 20, | |
| page: page | |
| }); | |
| if (query.trim()) { | |
| params.append('query', query.trim()); | |
| } | |
| if (sort !== 'Newest') { | |
| params.append('sort', sort); | |
| } | |
| if (nsfw !== 'None') { | |
| params.append('nsfw', nsfw); | |
| } | |
| console.log('Fetching from Civitai API with params:', params.toString()); | |
| const response = await fetch(`https://civitai.com/api/v1/models?${params.toString()}`, { | |
| method: 'GET', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| } | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`API request failed: ${response.status} ${response.statusText}`); | |
| } | |
| const data = await response.json(); | |
| console.log('Civitai API response:', data); | |
| // Hide loading | |
| loadingEl.style.display = 'none'; | |
| if (data.items && data.items.length > 0) { | |
| this.renderBrowseModels(data.items); | |
| this.updateBrowsePagination(data.metadata || {}, page); | |
| } else { | |
| emptyStateEl.style.display = 'block'; | |
| } | |
| } catch (error) { | |
| console.error('Error loading browse models:', error); | |
| loadingEl.style.display = 'none'; | |
| emptyStateEl.style.display = 'block'; | |
| this.showToast('Failed to load models from Civitai', 'error'); | |
| } | |
| } | |
| renderBrowseModels(models) { | |
| const gridEl = document.getElementById('browseModelsGrid'); | |
| gridEl.innerHTML = models.map(model => { | |
| // Get the first image or use placeholder | |
| const imageUrl = model.modelVersions?.[0]?.images?.[0]?.url || ''; | |
| // Extract stats | |
| const stats = model.stats || {}; | |
| const downloadCount = this.formatNumber(stats.downloadCount || 0); | |
| const favoriteCount = this.formatNumber(stats.favoriteCount || 0); | |
| const rating = stats.rating ? stats.rating.toFixed(1) : 'N/A'; | |
| // Extract tags (first 3) | |
| const tags = (model.tags || []).slice(0, 3); | |
| // Get URN from latest version | |
| const latestVersion = model.modelVersions?.[0]; | |
| const urn = latestVersion ? `urn:air:${model.type}:checkpoint:civitai:${model.id}@${latestVersion.id}` : ''; | |
| return ` | |
| <div class="browse-model-card" data-model-id="${model.id}" data-model-name="${model.name}" data-model-urn="${urn}"> | |
| <button class="browse-add-btn" onclick="modelManager.addModelFromBrowser('${model.name}', '${urn}')" title="Add this model"> | |
| <i class="fas fa-plus"></i> | |
| </button> | |
| ${imageUrl ? `<img src="${imageUrl}" alt="${model.name}" class="browse-model-image">` : '<div class="browse-model-image"></div>'} | |
| <div class="browse-model-info"> | |
| <div class="browse-model-title">${model.name}</div> | |
| <div class="browse-model-stats"> | |
| <span><i class="fas fa-download"></i> ${downloadCount}</span> | |
| <span><i class="fas fa-heart"></i> ${favoriteCount}</span> | |
| <span><i class="fas fa-star"></i> ${rating}</span> | |
| </div> | |
| ${tags.length > 0 ? ` | |
| <div class="browse-model-tags"> | |
| ${tags.map(tag => `<span class="browse-model-tag">${tag}</span>`).join('')} | |
| </div> | |
| ` : ''} | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| } | |
| updateBrowsePagination(metadata, currentPage) { | |
| const paginationEl = document.getElementById('browsePagination'); | |
| const pageInfoEl = document.getElementById('pageInfo'); | |
| const prevBtn = document.getElementById('prevPageBtn'); | |
| const nextBtn = document.getElementById('nextPageBtn'); | |
| if (metadata.totalPages > 1) { | |
| paginationEl.style.display = 'flex'; | |
| pageInfoEl.textContent = `Page ${currentPage} of ${metadata.totalPages}`; | |
| prevBtn.disabled = currentPage <= 1; | |
| nextBtn.disabled = currentPage >= metadata.totalPages; | |
| } else { | |
| paginationEl.style.display = 'none'; | |
| } | |
| } | |
| addModelFromBrowser(modelName, modelUrn) { | |
| console.log('Adding model from browser:', modelName, modelUrn); | |
| // Fill the form fields | |
| document.getElementById('displayName').value = modelName; | |
| document.getElementById('urn').value = modelUrn; | |
| // Close the browse modal | |
| this.hideBrowseModal(); | |
| // Show success message | |
| this.showToast(`Model "${modelName}" added to form`, 'success'); | |
| } | |
| searchBrowseModels() { | |
| const query = document.getElementById('modelSearchInput').value; | |
| const sort = document.getElementById('sortModelsSelect').value; | |
| const nsfw = document.getElementById('nsfwLevelSelect').value; | |
| console.log('Searching with:', { query, sort, nsfw }); | |
| this.loadBrowseModels(query, sort, nsfw, 1); | |
| } | |
| formatNumber(num) { | |
| if (num >= 1000000) { | |
| return (num / 1000000).toFixed(1) + 'M'; | |
| } else if (num >= 1000) { | |
| return (num / 1000).toFixed(1) + 'K'; | |
| } | |
| return num.toString(); | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // Bot Management Methods | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| async restartBot() { | |
| if (!confirm('Are you sure you want to restart the Discord bot? This will cause a brief disconnection.')) { | |
| return; | |
| } | |
| const btn = document.getElementById('restartBotBtn'); | |
| const status = document.getElementById('botStatus'); | |
| const statusText = status.querySelector('.status-text'); | |
| // Update UI to show processing | |
| btn.disabled = true; | |
| btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading Models and Restarting...'; | |
| status.className = 'status-indicator processing'; | |
| statusText.textContent = 'Restarting...'; | |
| try { | |
| // Use proxy endpoint that handles Firebase-stored credentials | |
| const response = await fetch('/api/bot-proxy/restart-bot', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| } | |
| }); | |
| if (!response.ok) { | |
| const error = await response.json(); | |
| throw new Error(error.detail || 'Failed to restart bot'); | |
| } | |
| const result = await response.json(); | |
| // Success | |
| status.className = 'status-indicator'; | |
| statusText.textContent = 'Bot Restarted'; | |
| this.showToast('Success', result.message, 'success'); | |
| // Reset status after 10 seconds (restart takes time) | |
| setTimeout(() => { | |
| statusText.textContent = 'Ready'; | |
| }, 10000); | |
| } catch (error) { | |
| // Error | |
| status.className = 'status-indicator error'; | |
| statusText.textContent = 'Error'; | |
| this.showToast('Error restarting bot', error.message, 'error'); | |
| console.error('Error restarting bot:', error); | |
| // Reset status after 5 seconds | |
| setTimeout(() => { | |
| statusText.textContent = 'Ready'; | |
| status.className = 'status-indicator'; | |
| }, 5000); | |
| } finally { | |
| // Reset button after delay (restart takes time) | |
| setTimeout(() => { | |
| btn.disabled = false; | |
| btn.innerHTML = '<i class="fas fa-power-off"></i> Load Models and Restart Bot'; | |
| }, 3000); | |
| } | |
| } | |
| async checkBotStatus() { | |
| const btn = document.getElementById('checkStatusBtn'); | |
| const botConnected = document.getElementById('botConnected'); | |
| const firebaseStatus = document.getElementById('firebaseStatus'); | |
| const lastCheck = document.getElementById('lastCheck'); | |
| // Update UI to show loading | |
| btn.disabled = true; | |
| btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Checking...'; | |
| try { | |
| // Use proxy endpoint that handles Firebase-stored credentials | |
| const response = await fetch('/api/bot-proxy/status', { | |
| headers: { | |
| 'Accept': 'application/json' | |
| } | |
| }); | |
| if (!response.ok) { | |
| const error = await response.json(); | |
| throw new Error(error.detail || 'Failed to get bot status'); | |
| } | |
| const result = await response.json(); | |
| const data = result.data; | |
| // Update status display | |
| botConnected.textContent = data.bot_connected ? 'Connected' : 'Disconnected'; | |
| botConnected.className = `status-value ${data.bot_connected ? 'connected' : 'disconnected'}`; | |
| firebaseStatus.textContent = data.firebase_status?.firebase_available ? 'Available' : 'Unavailable'; | |
| firebaseStatus.className = `status-value ${data.firebase_status?.firebase_available ? 'connected' : 'disconnected'}`; | |
| lastCheck.textContent = new Date().toLocaleString(); | |
| lastCheck.className = 'status-value'; | |
| this.showToast('Status Updated', 'Bot status refreshed successfully', 'success'); | |
| } catch (error) { | |
| // Error - update with error state | |
| botConnected.textContent = 'Error'; | |
| botConnected.className = 'status-value disconnected'; | |
| firebaseStatus.textContent = 'Error'; | |
| firebaseStatus.className = 'status-value disconnected'; | |
| lastCheck.textContent = new Date().toLocaleString(); | |
| this.showToast('Error checking status', error.message, 'error'); | |
| console.error('Error checking bot status:', error); | |
| } finally { | |
| // Reset button | |
| btn.disabled = false; | |
| btn.innerHTML = '<i class="fas fa-info-circle"></i> Check Status'; | |
| } | |
| } | |
| getBotApiUrl() { | |
| // Check if bot API URL is configured in environment/localStorage | |
| const configuredUrl = localStorage.getItem('botApiUrl') || window.BOT_API_URL; | |
| if (configuredUrl) { | |
| return configuredUrl; | |
| } | |
| // Default behavior - try different common scenarios | |
| const host = window.location.hostname; | |
| const currentPort = window.location.port; | |
| // If we're on HuggingFace Spaces, the bot is likely elsewhere | |
| if (host.includes('hf.space') || host.includes('huggingface.co')) { | |
| // Return a configurable URL that needs to be set | |
| const defaultBotUrl = localStorage.getItem('botApiUrl'); | |
| if (!defaultBotUrl) { | |
| // Show configuration help | |
| this.showBotApiConfigHelp(); | |
| throw new Error('Bot API URL not configured. Please configure the Discord bot management API URL.'); | |
| } | |
| return defaultBotUrl; | |
| } | |
| // For local development, assume same host different port | |
| const port = '8080'; // The management API port we configured | |
| const protocol = window.location.protocol; | |
| return `${protocol}//${host}:${port}`; | |
| } | |
| showBotApiConfigHelp() { | |
| const helpMessage = ` | |
| <div style="background: #f8f9fa; padding: 15px; border-radius: 8px; margin: 10px 0;"> | |
| <h4>🔧 Bot API Configuration Required</h4> | |
| <p>The Discord bot management API URL needs to be configured.</p> | |
| <p><strong>Steps:</strong></p> | |
| <ol> | |
| <li>Find where your Discord bot is running (IP address and port 8080)</li> | |
| <li>Open browser console (F12)</li> | |
| <li>Run: <code>localStorage.setItem('botApiUrl', 'http://YOUR_BOT_IP:8080')</code></li> | |
| <li>Replace YOUR_BOT_IP with actual IP address</li> | |
| <li>Refresh page and try again</li> | |
| </ol> | |
| <p><strong>Example:</strong> <code>localStorage.setItem('botApiUrl', 'http://192.168.1.100:8080')</code></p> | |
| </div>`; | |
| this.showToast('Configuration Required', helpMessage, 'info', 10000); | |
| } | |
| async getBotApiKey() { | |
| // Priority: localStorage > Firebase > environment variable > default | |
| const storedKey = localStorage.getItem('discordAdminKey'); | |
| if (storedKey) { | |
| return storedKey; | |
| } | |
| // Try to get from Firebase via API | |
| try { | |
| const response = await fetch('/api/bot-config'); | |
| if (response.ok) { | |
| const config = await response.json(); | |
| if (config.auto_configured) { | |
| // We can't return the actual key for security, but we know it exists | |
| // The actual API calls will need to use a different approach | |
| return 'FIREBASE_STORED_KEY'; // Special placeholder | |
| } | |
| } | |
| } catch (error) { | |
| console.log('Error getting bot API key from Firebase:', error); | |
| } | |
| // Fallback to environment variable or default | |
| return window.DISCORD_ADMIN_KEY || 'discord-bot-admin-key-change-me'; | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| // Configuration Methods | |
| // ═══════════════════════════════════════════════════════════════════════════════ | |
| async initBotApiConfig() { | |
| const urlInput = document.getElementById('botApiUrlInput'); | |
| const keyInput = document.getElementById('discordAdminKeyInput'); | |
| const status = document.getElementById('configStatus'); | |
| const statusText = status.querySelector('.status-text'); | |
| // Load configuration from Firebase (via API) | |
| try { | |
| const response = await fetch('/api/bot-config'); | |
| if (response.ok) { | |
| const config = await response.json(); | |
| if (config.auto_configured) { | |
| // Configuration found in Firebase or environment | |
| urlInput.value = config.bot_api_url || ''; | |
| if (config.stored_in_firebase) { | |
| urlInput.placeholder = 'Stored in Firebase'; | |
| keyInput.placeholder = 'Stored in Firebase (hidden)'; | |
| statusText.textContent = 'Configured in Firebase'; | |
| } else { | |
| urlInput.placeholder = 'From environment variable'; | |
| keyInput.placeholder = 'From environment variable (hidden)'; | |
| statusText.textContent = 'Configured from environment'; | |
| } | |
| // Save URL to localStorage for API calls | |
| if (config.bot_api_url) { | |
| localStorage.setItem('botApiUrl', config.bot_api_url); | |
| } | |
| if (config.discord_admin_key_set) { | |
| // Don't show the actual key, but mark as configured | |
| keyInput.placeholder = 'Admin key configured in Firebase (hidden for security)'; | |
| keyInput.title = 'Admin key is configured (hidden for security)'; | |
| } | |
| status.className = 'config-status configured'; | |
| return; | |
| } | |
| } | |
| } catch (error) { | |
| console.log('Error loading bot configuration:', error); | |
| } | |
| // Fallback to saved configuration in localStorage | |
| const savedUrl = localStorage.getItem('botApiUrl'); | |
| const savedKey = localStorage.getItem('discordAdminKey'); | |
| if (savedUrl) { | |
| urlInput.value = savedUrl; | |
| } | |
| // Don't populate the admin key field - keep it blank for security | |
| // Update status based on configuration | |
| if (savedUrl && savedKey) { | |
| status.className = 'config-status configured'; | |
| statusText.textContent = 'Configured locally'; | |
| } else { | |
| status.className = 'config-status'; | |
| statusText.textContent = 'Not configured - Both URL and admin key required'; | |
| } | |
| } | |
| async testBotConnection() { | |
| const urlInput = document.getElementById('botApiUrlInput'); | |
| const keyInput = document.getElementById('discordAdminKeyInput'); | |
| const btn = document.getElementById('testBotApiUrl'); | |
| const status = document.getElementById('configStatus'); | |
| const statusText = status.querySelector('.status-text'); | |
| const url = urlInput.value.trim(); | |
| let adminKey = keyInput.value.trim(); | |
| // Clean admin key of any non-ASCII characters (like bullet points from password masking) | |
| adminKey = adminKey.replace(/[^\x00-\x7F]/g, ""); | |
| console.log('Cleaned admin key length:', adminKey.length); | |
| if (!url) { | |
| this.showToast('Error', 'Please enter a URL first', 'error'); | |
| return; | |
| } | |
| if (!adminKey) { | |
| this.showToast('Error', 'Please enter the Discord admin key first', 'error'); | |
| return; | |
| } | |
| // Update UI | |
| btn.disabled = true; | |
| btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Testing...'; | |
| status.className = 'config-status testing'; | |
| statusText.textContent = 'Testing connection...'; | |
| try { | |
| // Since direct ngrok connection keeps showing warning page, | |
| // save config to Firebase and use the proxy approach | |
| console.log('Saving configuration and testing via proxy...'); | |
| const configData = { | |
| bot_api_url: url.trim(), | |
| discord_admin_key: adminKey | |
| }; | |
| // Save to Firebase first | |
| const saveResponse = await fetch('/api/bot-config', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify(configData) | |
| }); | |
| if (!saveResponse.ok) { | |
| throw new Error('Failed to save configuration to Firebase'); | |
| } | |
| console.log('Configuration saved, testing proxy connection...'); | |
| // Test via proxy to bypass ngrok warning page | |
| const response = await fetch('/api/bot-proxy/status', { | |
| method: 'GET' | |
| }); | |
| console.log('Response status:', response.status); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| console.log('Proxy response:', data); | |
| // Authentication successful | |
| status.className = 'config-status configured'; | |
| statusText.textContent = 'Connection & auth successful'; | |
| this.showToast('Success', 'Successfully connected to Discord bot management API via proxy', 'success'); | |
| // Auto-save the working configuration | |
| localStorage.setItem('botApiUrl', url); | |
| localStorage.setItem('discordAdminKey', adminKey); | |
| } else if (response.status === 401) { | |
| status.className = 'config-status error'; | |
| statusText.textContent = 'Invalid admin key'; | |
| throw new Error('Authentication failed. Please check your Discord admin key.'); | |
| } else { | |
| const errorData = await response.json(); | |
| throw new Error(errorData.detail || `HTTP ${response.status}: ${response.statusText}`); | |
| } | |
| } catch (error) { | |
| status.className = 'config-status error'; | |
| statusText.textContent = 'Connection failed'; | |
| let errorMessage = 'Failed to connect to Discord bot management API'; | |
| if (error.message.includes('Failed to fetch')) { | |
| errorMessage += ' - Check if the bot is running and the URL is correct'; | |
| } else { | |
| errorMessage += ` - ${error.message}`; | |
| } | |
| this.showToast('Connection Failed', errorMessage, 'error'); | |
| console.error('Bot API connection test failed:', error); | |
| } finally { | |
| // Reset button | |
| btn.disabled = false; | |
| btn.innerHTML = '<i class="fas fa-wifi"></i> Test Connection'; | |
| // Reset status after delay if it was testing | |
| if (status.classList.contains('testing')) { | |
| setTimeout(() => { | |
| const savedUrl = localStorage.getItem('botApiUrl'); | |
| if (savedUrl) { | |
| status.className = 'config-status configured'; | |
| statusText.textContent = 'Configured'; | |
| } else { | |
| status.className = 'config-status'; | |
| statusText.textContent = 'Not configured'; | |
| } | |
| }, 3000); | |
| } | |
| } | |
| } | |
| } | |
| // Initialize the application | |
| let modelManager; | |
| document.addEventListener('DOMContentLoaded', () => { | |
| modelManager = new ModelManager(); | |
| }); | |
| // Handle keyboard shortcuts | |
| document.addEventListener('keydown', (e) => { | |
| // Escape to close modal | |
| if (e.key === 'Escape') { | |
| const modal = document.getElementById('modalOverlay'); | |
| if (modal.classList.contains('active')) { | |
| modal.classList.remove('active'); | |
| document.body.style.overflow = ''; | |
| } | |
| } | |
| // Ctrl/Cmd + R to refresh | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'r') { | |
| e.preventDefault(); | |
| location.reload(); | |
| } | |
| }); |