NikaMimi's picture
Upload 9 files
7ca7dce verified
// 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();
}
});