diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js new file mode 100644 index 00000000..3dae5e81 --- /dev/null +++ b/static/js/api/loraApi.js @@ -0,0 +1,238 @@ +import { state } from '../state/index.js'; +import { showToast } from '../utils/uiHelpers.js'; +import { createLoraCard } from '../components/LoraCard.js'; +import { initializeInfiniteScroll } from '../utils/infiniteScroll.js'; +import { showDeleteModal } from '../utils/modalUtils.js'; + +export async function loadMoreLoras() { + if (state.isLoading || !state.hasMore) return; + + state.isLoading = true; + try { + const params = new URLSearchParams({ + page: state.currentPage, + page_size: 20, + sort_by: state.sortBy + }); + + if (state.activeFolder !== null) { + params.append('folder', state.activeFolder); + } + + console.log('Loading loras with params:', params.toString()); + + const response = await fetch(`/api/loras?${params}`); + if (!response.ok) { + throw new Error(`Failed to fetch loras: ${response.statusText}`); + } + + const data = await response.json(); + console.log('Received data:', data); + + if (data.items.length === 0 && state.currentPage === 1) { + const grid = document.getElementById('loraGrid'); + grid.innerHTML = '
No loras found in this folder
'; + state.hasMore = false; + } else if (data.items.length > 0) { + state.hasMore = state.currentPage < data.total_pages; + state.currentPage++; + appendLoraCards(data.items); + + const sentinel = document.getElementById('scroll-sentinel'); + if (sentinel && state.observer) { + state.observer.observe(sentinel); + } + } else { + state.hasMore = false; + } + + } catch (error) { + console.error('Error loading loras:', error); + showToast('Failed to load loras: ' + error.message, 'error'); + } finally { + state.isLoading = false; + } +} + +export async function fetchCivitai() { + let ws = null; + + await state.loadingManager.showWithProgress(async (loading) => { + try { + ws = new WebSocket(`ws://${window.location.host}/ws/fetch-progress`); + + const operationComplete = new Promise((resolve, reject) => { + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + switch(data.status) { + case 'started': + loading.setStatus('Starting metadata fetch...'); + break; + + case 'processing': + const percent = ((data.processed / data.total) * 100).toFixed(1); + loading.setProgress(percent); + loading.setStatus( + `Processing (${data.processed}/${data.total}) ${data.current_name}` + ); + break; + + case 'completed': + loading.setProgress(100); + loading.setStatus( + `Completed: Updated ${data.success} of ${data.processed} loras` + ); + resolve(); + break; + + case 'error': + reject(new Error(data.error)); + break; + } + }; + + ws.onerror = (error) => { + reject(new Error('WebSocket error: ' + error.message)); + }; + }); + + await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = reject; + }); + + const response = await fetch('/api/fetch-all-civitai', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + throw new Error('Failed to fetch metadata'); + } + + await operationComplete; + + await resetAndReload(); + + } catch (error) { + console.error('Error fetching metadata:', error); + showToast('Failed to fetch metadata: ' + error.message, 'error'); + } finally { + if (ws) { + ws.close(); + } + } + }, { + initialMessage: 'Connecting...', + completionMessage: 'Metadata update complete' + }); +} + +export async function deleteModel(filePath) { + showDeleteModal(filePath); +} + +export async function replacePreview(filePath) { + const loadingOverlay = document.getElementById('loading-overlay'); + const loadingStatus = document.querySelector('.loading-status'); + + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*,video/mp4'; + + input.onchange = async function() { + if (!input.files || !input.files[0]) return; + + const file = input.files[0]; + const formData = new FormData(); + formData.append('preview_file', file); + formData.append('model_path', filePath); + + try { + loadingOverlay.style.display = 'flex'; + loadingStatus.textContent = 'Uploading preview...'; + + const response = await fetch('/api/replace_preview', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error('Upload failed'); + } + + const data = await response.json(); + const newPreviewPath = `${data.preview_url}?t=${new Date().getTime()}`; + + const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + const previewContainer = card.querySelector('.card-preview'); + const oldPreview = previewContainer.querySelector('img, video'); + + if (file.type.startsWith('video/')) { + const video = document.createElement('video'); + video.controls = true; + video.autoplay = true; + video.muted = true; + video.loop = true; + video.src = newPreviewPath; + oldPreview.replaceWith(video); + } else { + const img = document.createElement('img'); + img.src = newPreviewPath; + oldPreview.replaceWith(img); + } + + } catch (error) { + console.error('Error uploading preview:', error); + alert('Failed to upload preview image'); + } finally { + loadingOverlay.style.display = 'none'; + } + }; + + input.click(); +} + +function appendLoraCards(loras) { + const grid = document.getElementById('loraGrid'); + const sentinel = document.getElementById('scroll-sentinel'); + + loras.forEach(lora => { + const card = createLoraCard(lora); + grid.insertBefore(card, sentinel); + }); +} + +export async function resetAndReload() { + console.log('Resetting with state:', { ...state }); + + state.currentPage = 1; + state.hasMore = true; + state.isLoading = false; + + const grid = document.getElementById('loraGrid'); + grid.innerHTML = ''; + + const sentinel = document.createElement('div'); + sentinel.id = 'scroll-sentinel'; + grid.appendChild(sentinel); + + initializeInfiniteScroll(); + + await loadMoreLoras(); +} + +export async function refreshLoras() { + try { + state.loadingManager.showSimpleLoading('Refreshing loras...'); + await resetAndReload(); + showToast('Refresh complete', 'success'); + } catch (error) { + console.error('Refresh failed:', error); + showToast('Failed to refresh loras', 'error'); + } finally { + state.loadingManager.hide(); + state.loadingManager.restoreProgressBar(); + } +} \ No newline at end of file diff --git a/static/js/components/LoraCard.js b/static/js/components/LoraCard.js new file mode 100644 index 00000000..faf6d52f --- /dev/null +++ b/static/js/components/LoraCard.js @@ -0,0 +1,210 @@ +import { showToast } from '../utils/uiHelpers.js'; +import { modalManager } from '../managers/ModalManager.js'; + +export function createLoraCard(lora) { + const card = document.createElement('div'); + card.className = 'lora-card'; + card.dataset.sha256 = lora.sha256; + card.dataset.filepath = lora.file_path; + card.dataset.name = lora.model_name; + card.dataset.file_name = lora.file_name; + card.dataset.folder = lora.folder; + card.dataset.modified = lora.modified; + card.dataset.from_civitai = lora.from_civitai; + card.dataset.meta = JSON.stringify(lora.civitai || {}); + + // Add timestamp to preview URL to prevent caching + const previewUrl = lora.preview_url ? `${lora.preview_url}?t=${Date.now()}` : '/loras_static/images/no-preview.png'; + + card.innerHTML = ` +
+ ${previewUrl.endsWith('.mp4') ? + `` : + `${lora.model_name}` + } +
+ + ${lora.base_model} + +
+ + + + + + +
+
+ +
+ `; + + card.addEventListener('click', () => { + const meta = JSON.parse(card.dataset.meta || '{}'); + if (Object.keys(meta).length) { + showLoraModal(meta); + } else { + showToast( + card.dataset.from_civitai === 'true' ? + 'Click "Fetch" to retrieve metadata' : + 'No CivitAI information available', + 'info' + ); + } + }); + + return card; +} + +export function updatePreviewInCard(filePath, file, previewUrl) { + const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + const previewContainer = card?.querySelector('.card-preview'); + const oldPreview = previewContainer?.querySelector('img, video'); + + if (oldPreview) { + const newPreviewUrl = `${previewUrl}?t=${Date.now()}`; + const newPreview = file.type.startsWith('video/') + ? createVideoPreview(newPreviewUrl) + : createImagePreview(newPreviewUrl); + oldPreview.replaceWith(newPreview); + } +} + +export function showLoraModal(lora) { + const escapedWords = lora.trainedWords?.length ? + lora.trainedWords.map(word => word.replace(/'/g, '\\\'')) : []; + + const categories = {}; + escapedWords.forEach(word => { + const category = word.includes(':') ? word.split(':')[0] : 'General'; + if (!categories[category]) { + categories[category] = []; + } + categories[category].push(word); + }); + + const imageMarkup = lora.images.map(img => { + if (img.type === 'video') { + return ``; + } else { + return `Preview`; + } + }).join(''); + + const triggerWordsMarkup = escapedWords.length ? ` +
+
Trigger Words
+
+ ${escapedWords.map(word => ` +
+ ${word} + + + + + +
+ `).join('')} +
+
+ ` : '
No trigger words
'; + + const content = ` + + `; + + modalManager.showModal('loraModal', content); + + document.querySelectorAll('.trigger-category').forEach(category => { + category.addEventListener('click', function() { + const categoryName = this.dataset.category; + document.querySelectorAll('.trigger-category').forEach(c => c.classList.remove('active')); + this.classList.add('active'); + + const wordsList = document.querySelector('.trigger-words-list'); + wordsList.innerHTML = categories[categoryName].map(word => ` +
+ ${word} + + + + + +
+ `).join(''); + }); + }); +} + +export function initializeLoraCards() { + document.querySelectorAll('.lora-card').forEach(card => { + card.addEventListener('click', () => { + const meta = JSON.parse(card.dataset.meta || '{}'); + if (Object.keys(meta).length) { + showLoraModal(meta); + } else { + showToast(card.dataset.from_civitai === 'True' + ? 'Click "Fetch" to retrieve metadata' + : 'No CivitAI information available', 'info'); + } + }); + + card.querySelector('.fa-copy')?.addEventListener('click', e => { + e.stopPropagation(); + navigator.clipboard.writeText(card.dataset.file_name) + .then(() => showToast('Model name copied', 'success')) + .catch(() => showToast('Copy failed', 'error')); + }); + }); +} + +function createVideoPreview(url) { + const video = document.createElement('video'); + video.controls = video.autoplay = video.muted = video.loop = true; + video.src = url; + return video; +} + +function createImagePreview(url) { + const img = document.createElement('img'); + img.src = url; + return img; +} \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 00000000..ed4b543d --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,66 @@ +import { debounce } from './utils/debounce.js'; +import { LoadingManager } from './managers/LoadingManager.js'; +import { modalManager } from './managers/ModalManager.js'; +import { state } from './state/index.js'; +import { createLoraCard, updatePreviewInCard, showLoraModal, initializeLoraCards } from './components/LoraCard.js'; +import { loadMoreLoras, fetchCivitai, deleteModel, replacePreview, resetAndReload, refreshLoras } from './api/loraApi.js'; +import { showToast, lazyLoadImages, restoreFolderFilter, initTheme, toggleTheme, toggleFolder, copyTriggerWord } from './utils/uiHelpers.js'; +import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; +import { showDeleteModal, confirmDelete, closeDeleteModal } from './utils/modalUtils.js'; + +// Export all functions that need global access +window.loadMoreLoras = loadMoreLoras; +window.fetchCivitai = fetchCivitai; +window.deleteModel = deleteModel; +window.replacePreview = replacePreview; +window.toggleTheme = toggleTheme; +window.toggleFolder = toggleFolder; +window.copyTriggerWord = copyTriggerWord; +window.showLoraModal = showLoraModal; +window.modalManager = modalManager; +window.state = state; +window.confirmDelete = confirmDelete; +window.closeDeleteModal = closeDeleteModal; +window.refreshLoras = refreshLoras; + +// Initialize everything when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + state.loadingManager = new LoadingManager(); + modalManager.initialize(); // Initialize modalManager after DOM is loaded + initializeInfiniteScroll(); + initializeEventListeners(); + lazyLoadImages(); + restoreFolderFilter(); + initializeLoraCards(); + initTheme(); + + // Search handler + const searchHandler = debounce(term => { + document.querySelectorAll('.lora-card').forEach(card => { + card.style.display = [card.dataset.name, card.dataset.folder] + .some(text => text.toLowerCase().includes(term)) + ? 'block' + : 'none'; + }); + }, 250); + + document.getElementById('searchInput')?.addEventListener('input', e => { + searchHandler(e.target.value.toLowerCase()); + }); +}); + +// Initialize event listeners +function initializeEventListeners() { + const sortSelect = document.getElementById('sortSelect'); + if (sortSelect) { + sortSelect.value = state.sortBy; + sortSelect.addEventListener('change', async (e) => { + state.sortBy = e.target.value; + await resetAndReload(); + }); + } + + document.querySelectorAll('.folder-tags .tag').forEach(tag => { + tag.addEventListener('click', toggleFolder); + }); +} \ No newline at end of file diff --git a/static/js/managers/LoadingManager.js b/static/js/managers/LoadingManager.js new file mode 100644 index 00000000..9714f4f2 --- /dev/null +++ b/static/js/managers/LoadingManager.js @@ -0,0 +1,57 @@ +// Loading management +export class LoadingManager { + constructor() { + this.overlay = document.getElementById('loading-overlay'); + this.progressBar = this.overlay.querySelector('.progress-bar'); + this.statusText = this.overlay.querySelector('.loading-status'); + } + + show(message = 'Loading...', progress = 0) { + this.overlay.style.display = 'flex'; + this.setProgress(progress); + this.setStatus(message); + } + + hide() { + this.overlay.style.display = 'none'; + this.reset(); + } + + setProgress(percent) { + this.progressBar.style.width = `${percent}%`; + this.progressBar.setAttribute('aria-valuenow', percent); + } + + setStatus(message) { + this.statusText.textContent = message; + } + + reset() { + this.setProgress(0); + this.setStatus(''); + } + + async showWithProgress(callback, options = {}) { + const { initialMessage = 'Processing...', completionMessage = 'Complete' } = options; + + try { + this.show(initialMessage); + await callback(this); + this.setProgress(100); + this.setStatus(completionMessage); + await new Promise(resolve => setTimeout(resolve, 500)); + } finally { + this.hide(); + } + } + + showSimpleLoading(message = 'Loading...') { + this.overlay.style.display = 'flex'; + this.progressBar.style.display = 'none'; + this.setStatus(message); + } + + restoreProgressBar() { + this.progressBar.style.display = 'block'; + } +} \ No newline at end of file diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js new file mode 100644 index 00000000..58c0d5ae --- /dev/null +++ b/static/js/managers/ModalManager.js @@ -0,0 +1,91 @@ +export class ModalManager { + constructor() { + this.modals = new Map(); + } + + initialize() { + if (this.initialized) return; + + this.boundHandleEscape = this.handleEscape.bind(this); + + // Register all modals + this.registerModal('loraModal', { + element: document.getElementById('loraModal'), + onClose: () => { + this.getModal('loraModal').element.style.display = 'none'; + document.body.classList.remove('modal-open'); + } + }); + + this.registerModal('deleteModal', { + element: document.getElementById('deleteModal'), + onClose: () => { + this.getModal('deleteModal').element.classList.remove('show'); + document.body.classList.remove('modal-open'); + } + }); + + document.addEventListener('keydown', this.boundHandleEscape); + this.initialized = true; + } + + registerModal(id, config) { + this.modals.set(id, { + element: config.element, + onClose: config.onClose, + isOpen: false + }); + + // Add click outside to close for each modal + config.element.addEventListener('click', (e) => { + if (e.target === config.element) { + this.closeModal(id); + } + }); + } + + getModal(id) { + return this.modals.get(id); + } + + showModal(id, content = null) { + const modal = this.getModal(id); + if (!modal) return; + + if (content) { + modal.element.innerHTML = content; + } + + if (id === 'loraModal') { + modal.element.style.display = 'block'; + } else if (id === 'deleteModal') { + modal.element.classList.add('show'); + } + + modal.isOpen = true; + document.body.classList.add('modal-open'); + } + + closeModal(id) { + const modal = this.getModal(id); + if (!modal) return; + + modal.onClose(); + modal.isOpen = false; + } + + handleEscape(e) { + if (e.key === 'Escape') { + // Close the last opened modal + for (const [id, modal] of this.modals) { + if (modal.isOpen) { + this.closeModal(id); + break; + } + } + } + } +} + +// Create and export a singleton instance +export const modalManager = new ModalManager(); \ No newline at end of file diff --git a/static/js/state/index.js b/static/js/state/index.js new file mode 100644 index 00000000..0a32af60 --- /dev/null +++ b/static/js/state/index.js @@ -0,0 +1,9 @@ +export const state = { + currentPage: 1, + isLoading: false, + hasMore: true, + sortBy: 'name', + activeFolder: null, + loadingManager: null, + observer: null +}; \ No newline at end of file diff --git a/static/js/utils/debounce.js b/static/js/utils/debounce.js new file mode 100644 index 00000000..0bf6b2ea --- /dev/null +++ b/static/js/utils/debounce.js @@ -0,0 +1,8 @@ +// Debounce function +export function debounce(func, wait) { + let timeout; + return function(...args) { + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(this, args), wait); + }; +} \ No newline at end of file diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js new file mode 100644 index 00000000..448ede8d --- /dev/null +++ b/static/js/utils/infiniteScroll.js @@ -0,0 +1,29 @@ +import { state } from '../state/index.js'; +import { loadMoreLoras } from '../api/loraApi.js'; + +export function initializeInfiniteScroll() { + if (state.observer) { + state.observer.disconnect(); + } + + state.observer = new IntersectionObserver( + (entries) => { + const target = entries[0]; + if (target.isIntersecting && !state.isLoading && state.hasMore) { + loadMoreLoras(); + } + }, + { threshold: 0.1 } + ); + + const existingSentinel = document.getElementById('scroll-sentinel'); + if (existingSentinel) { + state.observer.observe(existingSentinel); + } else { + const sentinel = document.createElement('div'); + sentinel.id = 'scroll-sentinel'; + sentinel.style.height = '10px'; + document.getElementById('loraGrid').appendChild(sentinel); + state.observer.observe(sentinel); + } +} \ No newline at end of file diff --git a/static/js/utils/modalUtils.js b/static/js/utils/modalUtils.js new file mode 100644 index 00000000..49dfe15d --- /dev/null +++ b/static/js/utils/modalUtils.js @@ -0,0 +1,56 @@ +import { modalManager } from '../managers/ModalManager.js'; + +let pendingDeletePath = null; + +export function showDeleteModal(filePath) { + event.stopPropagation(); + pendingDeletePath = filePath; + + const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + const modelName = card.dataset.name; + const modal = modalManager.getModal('deleteModal').element; + const modelInfo = modal.querySelector('.delete-model-info'); + + modelInfo.innerHTML = ` + Model: ${modelName} +
+ File: ${filePath} + `; + + modalManager.showModal('deleteModal'); +} + +export async function confirmDelete() { + if (!pendingDeletePath) return; + + const modal = document.getElementById('deleteModal'); + const card = document.querySelector(`.lora-card[data-filepath="${pendingDeletePath}"]`); + + try { + const response = await fetch('/api/delete_model', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: pendingDeletePath + }) + }); + + if (response.ok) { + if (card) { + card.remove(); + } + closeDeleteModal(); + } else { + const error = await response.text(); + alert(`Failed to delete model: ${error}`); + } + } catch (error) { + alert(`Error deleting model: ${error}`); + } +} + +export function closeDeleteModal() { + modalManager.closeModal('deleteModal'); +} \ No newline at end of file diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js new file mode 100644 index 00000000..c0d47d16 --- /dev/null +++ b/static/js/utils/uiHelpers.js @@ -0,0 +1,88 @@ +import { state } from '../state/index.js'; +import { resetAndReload } from '../api/loraApi.js'; + +export function showToast(message, type = 'info') { + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + document.body.append(toast); + + requestAnimationFrame(() => { + toast.classList.add('show'); + setTimeout(() => toast.remove(), 2300); + }); +} + +export function lazyLoadImages() { + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + if (entry.isIntersecting && entry.target.dataset.src) { + entry.target.src = entry.target.dataset.src; + observer.unobserve(entry.target); + } + }); + }); + + document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img)); +} + +export function restoreFolderFilter() { + const activeFolder = localStorage.getItem('activeFolder'); + const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`); + if (folderTag) { + folderTag.classList.add('active'); + filterByFolder(activeFolder); + } +} + +export function initTheme() { + document.body.dataset.theme = localStorage.getItem('theme') || 'dark'; +} + +export function toggleTheme() { + const theme = document.body.dataset.theme === 'light' ? 'dark' : 'light'; + document.body.dataset.theme = theme; + localStorage.setItem('theme', theme); +} + +export function toggleFolder(tag) { + const tagElement = (tag instanceof HTMLElement) ? tag : this; + const folder = tagElement.dataset.folder; + const wasActive = tagElement.classList.contains('active'); + + document.querySelectorAll('.folder-tags .tag').forEach(t => { + t.classList.remove('active'); + }); + + if (!wasActive) { + tagElement.classList.add('active'); + state.activeFolder = folder; + } else { + state.activeFolder = null; + } + + resetAndReload(); +} + +export function copyTriggerWord(word) { + navigator.clipboard.writeText(word).then(() => { + const toast = document.createElement('div'); + toast.className = 'toast toast-copy'; + toast.textContent = 'Copied!'; + document.body.appendChild(toast); + + requestAnimationFrame(() => { + toast.classList.add('show'); + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, 1000); + }); + }); +} + +function filterByFolder(folderPath) { + document.querySelectorAll('.lora-card').forEach(card => { + card.style.display = card.dataset.folder === folderPath ? '' : 'none'; + }); +} \ No newline at end of file diff --git a/templates/components/controls.html b/templates/components/controls.html new file mode 100644 index 00000000..15f4b0ad --- /dev/null +++ b/templates/components/controls.html @@ -0,0 +1,16 @@ +
+
+ {% for folder in folders %} +
{{ folder }}
+ {% endfor %} +
+ +
+ + + +
+
\ No newline at end of file diff --git a/templates/components/loading.html b/templates/components/loading.html new file mode 100644 index 00000000..35cbdaec --- /dev/null +++ b/templates/components/loading.html @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/templates/components/modals.html b/templates/components/modals.html new file mode 100644 index 00000000..11dbb010 --- /dev/null +++ b/templates/components/modals.html @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/templates/loras.html b/templates/loras.html index d3485a93..646d03e0 100644 --- a/templates/loras.html +++ b/templates/loras.html @@ -1,7 +1,7 @@ - LoRA Management + LoRA Manager @@ -11,7 +11,7 @@ - + @@ -38,50 +38,11 @@ Theme - - - - - - - + {% include 'components/modals.html' %} + {% include 'components/loading.html' %}
- -
-
- {% for folder in folders %} -
{{ folder }}
- {% endfor %} -
- -
- - - -
-
+ {% include 'components/controls.html' %}
@@ -89,6 +50,6 @@
- + \ No newline at end of file