// 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 `
${this.createModelTitleLink(model.displayName, model.urn)}
${model.urn}
`; } 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 = `
${title}
${message}
`; 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 `
${displayName}
`; } else { // Fallback for non-Civitai models return `
${displayName}
`; } } 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 = '
Loading images...
'; 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 ` Model image ${index + 1} `; }).join(''); // Create indicators if (images.length > 1) { indicatorsContainer.innerHTML = images.map((_, index) => ` `).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 = `
${message}
`; } 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 = `
Full size image
`; 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 ? ` View on Civitai` : `N/A`; return ` ${timestamp} ${log.action.replace('_', ' ')} ${log.category.toUpperCase()} ${log.displayName} ${log.urn} ${urlCell} `; }).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 `
${imageUrl ? `${model.name}` : '
'}
${model.name}
${downloadCount} ${favoriteCount} ${rating}
${tags.length > 0 ? `
${tags.map(tag => `${tag}`).join('')}
` : ''}
`; }).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 = ' 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 = ' 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 = ' 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 = ' 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 = `

🔧 Bot API Configuration Required

The Discord bot management API URL needs to be configured.

Steps:

  1. Find where your Discord bot is running (IP address and port 8080)
  2. Open browser console (F12)
  3. Run: localStorage.setItem('botApiUrl', 'http://YOUR_BOT_IP:8080')
  4. Replace YOUR_BOT_IP with actual IP address
  5. Refresh page and try again

Example: localStorage.setItem('botApiUrl', 'http://192.168.1.100:8080')

`; 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 = ' 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 = ' 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(); } });