1393 lines
48 KiB
JavaScript
1393 lines
48 KiB
JavaScript
/**
|
|
* Model Browser Component
|
|
* Provides a file browser-style list for model selection
|
|
* Inspired by GenericFileBrowser but specialized for model cache display
|
|
*/
|
|
|
|
import { createButton, BUTTON_VARIANTS } from "../components/buttons.js";
|
|
import { createCard } from "../components/layout.js";
|
|
import { copyToClipboard } from "../components/clipboard.js";
|
|
import { createComponentLogger } from "../utils/logger.js";
|
|
|
|
// Component logger
|
|
const log = createComponentLogger('ModelBrowser');
|
|
|
|
/**
|
|
* Model Browser Class
|
|
* Handles model listing, selection, filtering, and display
|
|
*
|
|
* @example
|
|
* // Basic usage
|
|
* const browser = new ModelBrowser({
|
|
* selectionMode: 'single',
|
|
* showFileSize: true,
|
|
* onSelect: (hash, modelData) => {
|
|
* console.log('Selected:', modelData.path);
|
|
* }
|
|
* });
|
|
* browser.render(container);
|
|
* browser.updateModels(modelsArray);
|
|
*
|
|
* @example
|
|
* // With filtering
|
|
* browser.applyFilters({
|
|
* search: 'flux',
|
|
* type: 'checkpoints',
|
|
* sort: 'name-desc'
|
|
* });
|
|
*/
|
|
export class ModelBrowser {
|
|
/**
|
|
* Creates a new ModelBrowser instance
|
|
* @param {Object} options - Configuration options
|
|
* @param {string} options.selectionMode - 'single' or 'multi'
|
|
* @param {boolean} options.showThumbnails - Show model preview images
|
|
* @param {string} options.itemHeight - Height of each list item ('auto' or specific value)
|
|
* @param {boolean} options.groupByFolder - Group models by folder type
|
|
* @param {boolean} options.showFileSize - Display file size badge
|
|
* @param {boolean} options.showLastUsed - Display last used badge
|
|
* @param {boolean} options.allowQuickActions - Show quick action buttons on hover
|
|
* @param {Function} options.onSelect - Callback when model is selected
|
|
* @param {Function} options.onDoubleClick - Callback when model is double-clicked
|
|
* @param {Function} options.onQuickAction - Callback when quick action is triggered
|
|
*/
|
|
constructor(options = {}) {
|
|
this.options = {
|
|
selectionMode: 'single', // 'single' or 'multi'
|
|
showThumbnails: false, // Show model thumbnails (future enhancement)
|
|
itemHeight: 'auto', // Height of each item
|
|
groupByFolder: false, // Group by folder type (future enhancement)
|
|
showFileSize: true, // Display file size
|
|
showLastUsed: true, // Display last used date
|
|
allowQuickActions: true, // Show quick action buttons on hover
|
|
maxHeight: '400px', // Max height of browser container
|
|
...options
|
|
};
|
|
|
|
// Model data
|
|
this.models = []; // Full model list
|
|
this.filteredModels = []; // Filtered/sorted model list
|
|
this.currentFilters = {}; // Current filter state
|
|
|
|
// Selection state
|
|
this.selectedModels = new Set(); // Set of selected model hashes
|
|
this.hoveredModel = null; // Currently hovered model hash
|
|
|
|
// Folder hierarchy state
|
|
this.expandedFolders = new Set(); // Set of expanded folder paths
|
|
this.showHierarchy = options.showHierarchy !== undefined ? options.showHierarchy : true;
|
|
this.defaultExpanded = options.defaultExpanded !== undefined ? options.defaultExpanded : false;
|
|
|
|
// UI references
|
|
this.container = null;
|
|
this.listContainer = null;
|
|
this.emptyMessage = null;
|
|
|
|
// Event callbacks
|
|
this.callbacks = {
|
|
onSelect: options.onSelect || (() => {}),
|
|
onDoubleClick: options.onDoubleClick || (() => {}),
|
|
onQuickAction: options.onQuickAction || (() => {})
|
|
};
|
|
|
|
// Keyboard navigation state
|
|
this.highlightedIndex = -1;
|
|
|
|
// Bind methods
|
|
this.handleKeyDown = this.handleKeyDown.bind(this);
|
|
}
|
|
|
|
/**
|
|
* Renders the model browser UI
|
|
* @param {HTMLElement} parentContainer - Container to append the browser to
|
|
* @returns {HTMLElement} The browser container element
|
|
*/
|
|
render(parentContainer) {
|
|
// Create main container using layout component
|
|
this.container = createCard({
|
|
padding: '0',
|
|
styles: {
|
|
marginBottom: '15px',
|
|
overflow: 'hidden'
|
|
}
|
|
});
|
|
|
|
// Create header
|
|
const header = this.createHeader();
|
|
this.container.appendChild(header);
|
|
|
|
// Create scrollable list container
|
|
this.listContainer = document.createElement('div');
|
|
this.listContainer.className = 'model-browser-list';
|
|
this.listContainer.style.cssText = `
|
|
max-height: ${this.options.maxHeight};
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
background: #2a2a2a;
|
|
border-top: 1px solid #444;
|
|
`;
|
|
|
|
// Create empty message (initially hidden)
|
|
this.emptyMessage = document.createElement('div');
|
|
this.emptyMessage.className = 'model-browser-empty';
|
|
this.emptyMessage.style.cssText = `
|
|
padding: 40px 20px;
|
|
text-align: center;
|
|
color: #888;
|
|
font-style: italic;
|
|
display: none;
|
|
`;
|
|
this.emptyMessage.textContent = 'No models found';
|
|
this.listContainer.appendChild(this.emptyMessage);
|
|
|
|
this.container.appendChild(this.listContainer);
|
|
|
|
// Add to parent container
|
|
if (parentContainer) {
|
|
parentContainer.appendChild(this.container);
|
|
}
|
|
|
|
// Set up keyboard navigation and accessibility
|
|
this.container.addEventListener('keydown', this.handleKeyDown);
|
|
this.container.setAttribute('tabindex', '0');
|
|
this.container.setAttribute('role', 'application');
|
|
this.container.setAttribute('aria-label', 'Model Browser');
|
|
|
|
// Make list container a listbox for screen readers
|
|
this.listContainer.setAttribute('role', 'listbox');
|
|
this.listContainer.setAttribute('aria-label', 'Models');
|
|
|
|
return this.container;
|
|
}
|
|
|
|
/**
|
|
* Creates the header section
|
|
* @returns {HTMLElement} Header element
|
|
*/
|
|
createHeader() {
|
|
const header = document.createElement('div');
|
|
header.className = 'model-browser-header';
|
|
header.style.cssText = `
|
|
padding: 12px 15px;
|
|
background: #333;
|
|
border-bottom: 1px solid #444;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 12px;
|
|
`;
|
|
|
|
const leftSection = document.createElement('div');
|
|
leftSection.style.cssText = `
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
`;
|
|
|
|
const title = document.createElement('h4');
|
|
title.style.cssText = `
|
|
margin: 0;
|
|
color: #4CAF50;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
`;
|
|
title.textContent = 'Model Browser';
|
|
|
|
// View mode toggle button
|
|
const viewToggle = document.createElement('button');
|
|
viewToggle.className = 'view-toggle-btn';
|
|
viewToggle.textContent = this.showHierarchy ? '📁' : '📄';
|
|
viewToggle.title = this.showHierarchy ? 'Switch to flat view' : 'Switch to folder view';
|
|
viewToggle.style.cssText = `
|
|
background: #444;
|
|
color: #ddd;
|
|
border: 1px solid #555;
|
|
border-radius: 4px;
|
|
padding: 4px 8px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
transition: all 0.2s;
|
|
`;
|
|
|
|
viewToggle.addEventListener('click', () => {
|
|
this.showHierarchy = !this.showHierarchy;
|
|
viewToggle.textContent = this.showHierarchy ? '📁' : '📄';
|
|
viewToggle.title = this.showHierarchy ? 'Switch to flat view' : 'Switch to folder view';
|
|
|
|
// Toggle expand/collapse button visibility
|
|
if (this.expandCollapseBtn) {
|
|
this.expandCollapseBtn.style.display = this.showHierarchy ? 'block' : 'none';
|
|
}
|
|
|
|
this.renderList();
|
|
});
|
|
|
|
viewToggle.addEventListener('mouseenter', () => {
|
|
viewToggle.style.background = '#555';
|
|
});
|
|
|
|
viewToggle.addEventListener('mouseleave', () => {
|
|
viewToggle.style.background = '#444';
|
|
});
|
|
|
|
leftSection.appendChild(title);
|
|
leftSection.appendChild(viewToggle);
|
|
|
|
const rightSection = document.createElement('div');
|
|
rightSection.style.cssText = `
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
`;
|
|
|
|
// Expand/Collapse All button (only shown in hierarchy mode)
|
|
const expandCollapseBtn = document.createElement('button');
|
|
expandCollapseBtn.className = 'expand-collapse-btn';
|
|
expandCollapseBtn.textContent = 'Expand All';
|
|
expandCollapseBtn.title = 'Expand or collapse all folders';
|
|
expandCollapseBtn.style.cssText = `
|
|
background: #444;
|
|
color: #ddd;
|
|
border: 1px solid #555;
|
|
border-radius: 4px;
|
|
padding: 4px 8px;
|
|
cursor: pointer;
|
|
font-size: 11px;
|
|
transition: all 0.2s;
|
|
display: ${this.showHierarchy ? 'block' : 'none'};
|
|
`;
|
|
this.expandCollapseBtn = expandCollapseBtn;
|
|
|
|
expandCollapseBtn.addEventListener('click', () => {
|
|
if (this.expandedFolders.size > 0) {
|
|
// Collapse all
|
|
this.expandedFolders.clear();
|
|
expandCollapseBtn.textContent = 'Expand All';
|
|
} else {
|
|
// Expand all - need to collect all folder paths
|
|
this.expandAllFolders();
|
|
expandCollapseBtn.textContent = 'Collapse All';
|
|
}
|
|
this.renderList();
|
|
});
|
|
|
|
expandCollapseBtn.addEventListener('mouseenter', () => {
|
|
expandCollapseBtn.style.background = '#555';
|
|
});
|
|
|
|
expandCollapseBtn.addEventListener('mouseleave', () => {
|
|
expandCollapseBtn.style.background = '#444';
|
|
});
|
|
|
|
const count = document.createElement('span');
|
|
count.className = 'model-count';
|
|
count.style.cssText = `
|
|
color: #888;
|
|
font-size: 12px;
|
|
`;
|
|
count.textContent = '0 models';
|
|
this.countElement = count;
|
|
|
|
rightSection.appendChild(expandCollapseBtn);
|
|
rightSection.appendChild(count);
|
|
|
|
header.appendChild(leftSection);
|
|
header.appendChild(rightSection);
|
|
|
|
return header;
|
|
}
|
|
|
|
/**
|
|
* Creates a single model list item
|
|
* @param {Object} modelData - Model data object
|
|
* @param {number} index - Index in filtered list
|
|
* @param {number} depth - Nesting depth for indentation
|
|
* @returns {HTMLElement} Model item element
|
|
*/
|
|
createModelItem(modelData, index, depth = 0) {
|
|
const { hash, path, info } = modelData;
|
|
const isSelected = this.selectedModels.has(hash);
|
|
const isHighlighted = this.highlightedIndex === index;
|
|
|
|
// Create item container
|
|
const item = document.createElement('div');
|
|
item.className = 'model-browser-item';
|
|
item.dataset.hash = hash;
|
|
item.dataset.index = index;
|
|
|
|
// ARIA attributes for accessibility
|
|
item.setAttribute('role', 'option');
|
|
item.setAttribute('aria-selected', isSelected.toString());
|
|
const modelName = info?.model?.name || info?.name || path.split('/').pop();
|
|
item.setAttribute('aria-label', `Model: ${modelName}`);
|
|
|
|
item.style.cssText = `
|
|
padding: 12px 15px;
|
|
padding-left: ${15 + (depth * 20)}px;
|
|
border-bottom: 1px solid #333;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
background: ${isSelected ? '#2d5a2d' : isHighlighted ? '#353535' : '#2a2a2a'};
|
|
border-left: 4px solid ${isSelected ? '#4CAF50' : 'transparent'};
|
|
position: relative;
|
|
box-shadow: ${isSelected ? 'inset 0 0 10px rgba(76, 175, 80, 0.2)' : 'none'};
|
|
`;
|
|
|
|
// Hover effects
|
|
item.addEventListener('mouseenter', () => {
|
|
if (!isSelected) {
|
|
item.style.background = '#353535';
|
|
} else {
|
|
item.style.background = '#358a35';
|
|
}
|
|
this.hoveredModel = hash;
|
|
|
|
// Show quick actions if enabled
|
|
if (this.options.allowQuickActions) {
|
|
const quickActions = item.querySelector('.model-quick-actions');
|
|
if (quickActions) {
|
|
quickActions.style.opacity = '1';
|
|
quickActions.style.pointerEvents = 'auto';
|
|
}
|
|
}
|
|
});
|
|
|
|
item.addEventListener('mouseleave', () => {
|
|
if (!isSelected) {
|
|
item.style.background = isHighlighted ? '#353535' : '#2a2a2a';
|
|
} else {
|
|
item.style.background = '#2d5a2d';
|
|
}
|
|
this.hoveredModel = null;
|
|
|
|
// Hide quick actions
|
|
if (this.options.allowQuickActions) {
|
|
const quickActions = item.querySelector('.model-quick-actions');
|
|
if (quickActions) {
|
|
quickActions.style.opacity = '0';
|
|
quickActions.style.pointerEvents = 'none';
|
|
}
|
|
}
|
|
});
|
|
|
|
// Main content
|
|
const content = this.createModelItemContent(modelData);
|
|
item.appendChild(content);
|
|
|
|
// Quick actions (if enabled)
|
|
if (this.options.allowQuickActions) {
|
|
const quickActions = this.createQuickActions(hash);
|
|
item.appendChild(quickActions);
|
|
}
|
|
|
|
// Click handler for selection
|
|
item.addEventListener('click', (e) => {
|
|
// Don't trigger if clicking on quick action buttons
|
|
if (e.target.closest('.model-quick-actions')) {
|
|
return;
|
|
}
|
|
this.handleModelSelect(hash, e.ctrlKey || e.metaKey);
|
|
});
|
|
|
|
// Double-click handler
|
|
item.addEventListener('dblclick', (e) => {
|
|
if (!e.target.closest('.model-quick-actions')) {
|
|
this.callbacks.onDoubleClick(hash, modelData);
|
|
}
|
|
});
|
|
|
|
return item;
|
|
}
|
|
|
|
/**
|
|
* Creates the main content for a model item
|
|
* @param {Object} modelData - Model data
|
|
* @returns {HTMLElement} Content element
|
|
*/
|
|
createModelItemContent(modelData) {
|
|
const { hash, path, info } = modelData;
|
|
|
|
const content = document.createElement('div');
|
|
content.className = 'model-item-content';
|
|
content.style.cssText = `
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
`;
|
|
|
|
// Top row: Icon + Name + Badges
|
|
const topRow = document.createElement('div');
|
|
topRow.style.cssText = `
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
`;
|
|
|
|
// Model type icon
|
|
const icon = this.getModelTypeIcon(info);
|
|
const iconSpan = document.createElement('span');
|
|
iconSpan.style.cssText = `
|
|
font-size: 16px;
|
|
flex-shrink: 0;
|
|
`;
|
|
iconSpan.textContent = icon;
|
|
topRow.appendChild(iconSpan);
|
|
|
|
// Model name (version name or filename)
|
|
const name = document.createElement('div');
|
|
name.style.cssText = `
|
|
flex: 1;
|
|
color: #fff;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
`;
|
|
|
|
const modelName = info?.model?.name || '';
|
|
const versionName = info?.name || '';
|
|
const displayName = versionName
|
|
? (modelName ? `${modelName} - ${versionName}` : versionName)
|
|
: (modelName || this.getFileNameFromPath(path));
|
|
|
|
name.textContent = displayName;
|
|
name.title = displayName; // Show full name on hover
|
|
topRow.appendChild(name);
|
|
|
|
// Metadata badge (if has metadata)
|
|
if (info && Object.keys(info).length > 0) {
|
|
const metaBadge = this.createBadge('✨', '#4CAF50', 'Has metadata');
|
|
topRow.appendChild(metaBadge);
|
|
}
|
|
|
|
content.appendChild(topRow);
|
|
|
|
// File path row
|
|
const pathRow = document.createElement('div');
|
|
pathRow.style.cssText = `
|
|
color: #888;
|
|
font-size: 11px;
|
|
padding-left: 24px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
`;
|
|
pathRow.textContent = path;
|
|
pathRow.title = path; // Show full path on hover
|
|
content.appendChild(pathRow);
|
|
|
|
// Metadata row: File size, Last used, etc.
|
|
if (this.options.showFileSize || this.options.showLastUsed) {
|
|
const metaRow = document.createElement('div');
|
|
metaRow.style.cssText = `
|
|
display: flex;
|
|
gap: 12px;
|
|
color: #888;
|
|
font-size: 11px;
|
|
padding-left: 24px;
|
|
align-items: center;
|
|
`;
|
|
|
|
// File size
|
|
if (this.options.showFileSize && info?.file_size) {
|
|
const sizeSpan = document.createElement('span');
|
|
sizeSpan.textContent = `📊 ${this.formatFileSize(info.file_size)}`;
|
|
metaRow.appendChild(sizeSpan);
|
|
}
|
|
|
|
// Last used
|
|
if (this.options.showLastUsed && info?.last_used) {
|
|
const lastUsedSpan = document.createElement('span');
|
|
lastUsedSpan.textContent = `🕐 ${this.formatLastUsed(info.last_used)}`;
|
|
metaRow.appendChild(lastUsedSpan);
|
|
}
|
|
|
|
// Has updates badge
|
|
if (info?.has_update) {
|
|
const updateBadge = this.createBadge('🆕', '#FF9800', 'Update available');
|
|
metaRow.appendChild(updateBadge);
|
|
}
|
|
|
|
if (metaRow.children.length > 0) {
|
|
content.appendChild(metaRow);
|
|
}
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
/**
|
|
* Creates quick action buttons for a model
|
|
* @param {string} hash - Model hash
|
|
* @returns {HTMLElement} Quick actions container
|
|
*/
|
|
createQuickActions(hash) {
|
|
const container = document.createElement('div');
|
|
container.className = 'model-quick-actions';
|
|
container.style.cssText = `
|
|
position: absolute;
|
|
right: 12px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
display: flex;
|
|
gap: 6px;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity 0.15s ease;
|
|
background: linear-gradient(to left, #2a2a2a 70%, transparent);
|
|
padding-left: 20px;
|
|
`;
|
|
|
|
// Copy hash button
|
|
const copyHashBtn = createButton('📋', {
|
|
variant: BUTTON_VARIANTS.SECONDARY,
|
|
title: 'Copy hash to clipboard',
|
|
onClick: () => {
|
|
copyToClipboard(hash, 'Model hash copied!');
|
|
this.callbacks.onQuickAction('copyHash', hash);
|
|
},
|
|
style: {
|
|
padding: '4px 8px',
|
|
fontSize: '12px',
|
|
minWidth: '30px',
|
|
height: '26px'
|
|
}
|
|
});
|
|
container.appendChild(copyHashBtn);
|
|
|
|
// Pull metadata button
|
|
const pullBtn = createButton('⬇️', {
|
|
variant: BUTTON_VARIANTS.SECONDARY,
|
|
title: 'Pull metadata from Civitai',
|
|
onClick: () => {
|
|
this.callbacks.onQuickAction('pull', hash);
|
|
},
|
|
style: {
|
|
padding: '4px 8px',
|
|
fontSize: '12px',
|
|
minWidth: '30px',
|
|
height: '26px'
|
|
}
|
|
});
|
|
container.appendChild(pullBtn);
|
|
|
|
// Edit button
|
|
const editBtn = createButton('✏️', {
|
|
variant: BUTTON_VARIANTS.SECONDARY,
|
|
title: 'Edit model information',
|
|
onClick: () => {
|
|
this.callbacks.onQuickAction('edit', hash);
|
|
},
|
|
style: {
|
|
padding: '4px 8px',
|
|
fontSize: '12px',
|
|
minWidth: '30px',
|
|
height: '26px'
|
|
}
|
|
});
|
|
container.appendChild(editBtn);
|
|
|
|
return container;
|
|
}
|
|
|
|
/**
|
|
* Creates a badge element
|
|
* @param {string} text - Badge text
|
|
* @param {string} color - Badge color
|
|
* @param {string} title - Tooltip text
|
|
* @returns {HTMLElement} Badge element
|
|
*/
|
|
createBadge(text, color, title) {
|
|
const badge = document.createElement('span');
|
|
badge.style.cssText = `
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 2px 6px;
|
|
background: ${color}22;
|
|
color: ${color};
|
|
border: 1px solid ${color}44;
|
|
border-radius: 3px;
|
|
font-size: 10px;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
`;
|
|
badge.textContent = text;
|
|
badge.title = title;
|
|
return badge;
|
|
}
|
|
|
|
/**
|
|
* Handles model selection
|
|
* @param {string} hash - Model hash
|
|
* @param {boolean} isMultiSelect - Whether Ctrl/Cmd key is pressed
|
|
*/
|
|
handleModelSelect(hash, isMultiSelect = false) {
|
|
if (this.options.selectionMode === 'single') {
|
|
// Single selection mode
|
|
this.selectedModels.clear();
|
|
this.selectedModels.add(hash);
|
|
} else {
|
|
// Multi selection mode
|
|
if (isMultiSelect) {
|
|
if (this.selectedModels.has(hash)) {
|
|
this.selectedModels.delete(hash);
|
|
} else {
|
|
this.selectedModels.add(hash);
|
|
}
|
|
} else {
|
|
this.selectedModels.clear();
|
|
this.selectedModels.add(hash);
|
|
}
|
|
}
|
|
|
|
// Update visual states
|
|
this.updateSelectionVisuals();
|
|
|
|
// Get model data
|
|
const modelData = this.filteredModels.find(m => m.hash === hash);
|
|
|
|
// Trigger callback
|
|
this.callbacks.onSelect(hash, modelData);
|
|
}
|
|
|
|
/**
|
|
* Updates visual selection states of all items
|
|
*/
|
|
updateSelectionVisuals() {
|
|
const items = this.listContainer.querySelectorAll('.model-browser-item');
|
|
items.forEach(item => {
|
|
const hash = item.dataset.hash;
|
|
const isSelected = this.selectedModels.has(hash);
|
|
const index = parseInt(item.dataset.index);
|
|
const isHighlighted = this.highlightedIndex === index;
|
|
|
|
// Update visual styling with more prominent selected state
|
|
item.style.background = isSelected ? '#2d5a2d' : isHighlighted ? '#353535' : '#2a2a2a';
|
|
item.style.borderLeftColor = isSelected ? '#4CAF50' : 'transparent';
|
|
item.style.borderLeftWidth = isSelected ? '4px' : '4px';
|
|
item.style.boxShadow = isSelected ? 'inset 0 0 10px rgba(76, 175, 80, 0.2)' : 'none';
|
|
|
|
// Update ARIA attributes for accessibility
|
|
item.setAttribute('aria-selected', isSelected.toString());
|
|
|
|
// Add focus indicator for highlighted item
|
|
if (isHighlighted) {
|
|
item.style.outline = '2px solid #4CAF50';
|
|
item.style.outlineOffset = '-2px';
|
|
} else {
|
|
item.style.outline = 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handles keyboard navigation
|
|
* @param {KeyboardEvent} e - Keyboard event
|
|
*/
|
|
handleKeyDown(e) {
|
|
if (this.filteredModels.length === 0 && e.key !== 'Escape') return;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
// Initialize to first item if no highlight
|
|
if (this.highlightedIndex === -1) {
|
|
this.highlightedIndex = 0;
|
|
} else {
|
|
this.highlightedIndex = Math.min(
|
|
this.highlightedIndex + 1,
|
|
this.filteredModels.length - 1
|
|
);
|
|
}
|
|
this.updateSelectionVisuals();
|
|
this.scrollToHighlighted();
|
|
break;
|
|
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
// Initialize to first item if no highlight
|
|
if (this.highlightedIndex === -1) {
|
|
this.highlightedIndex = 0;
|
|
} else {
|
|
this.highlightedIndex = Math.max(this.highlightedIndex - 1, 0);
|
|
}
|
|
this.updateSelectionVisuals();
|
|
this.scrollToHighlighted();
|
|
break;
|
|
|
|
case 'Enter':
|
|
case ' ': // Spacebar also selects
|
|
e.preventDefault();
|
|
if (this.highlightedIndex >= 0 && this.highlightedIndex < this.filteredModels.length) {
|
|
const model = this.filteredModels[this.highlightedIndex];
|
|
this.handleModelSelect(model.hash, e.ctrlKey || e.metaKey);
|
|
}
|
|
break;
|
|
|
|
case 'Home':
|
|
e.preventDefault();
|
|
this.highlightedIndex = 0;
|
|
this.updateSelectionVisuals();
|
|
this.scrollToHighlighted();
|
|
break;
|
|
|
|
case 'End':
|
|
e.preventDefault();
|
|
this.highlightedIndex = this.filteredModels.length - 1;
|
|
this.updateSelectionVisuals();
|
|
this.scrollToHighlighted();
|
|
break;
|
|
|
|
case 'Escape':
|
|
e.preventDefault();
|
|
this.clearSelection();
|
|
this.highlightedIndex = -1;
|
|
this.updateSelectionVisuals();
|
|
break;
|
|
|
|
case 'PageDown':
|
|
e.preventDefault();
|
|
// Jump down by ~10 items
|
|
this.highlightedIndex = Math.min(
|
|
this.highlightedIndex + 10,
|
|
this.filteredModels.length - 1
|
|
);
|
|
this.updateSelectionVisuals();
|
|
this.scrollToHighlighted();
|
|
break;
|
|
|
|
case 'PageUp':
|
|
e.preventDefault();
|
|
// Jump up by ~10 items
|
|
this.highlightedIndex = Math.max(this.highlightedIndex - 10, 0);
|
|
this.updateSelectionVisuals();
|
|
this.scrollToHighlighted();
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scrolls to the highlighted item
|
|
*/
|
|
scrollToHighlighted() {
|
|
const items = this.listContainer.querySelectorAll('.model-browser-item');
|
|
const highlightedItem = items[this.highlightedIndex];
|
|
|
|
if (highlightedItem) {
|
|
highlightedItem.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'nearest'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the model list
|
|
* @param {Array} models - Array of model objects {hash, path, info}
|
|
*/
|
|
updateModels(models) {
|
|
console.log('[ModelBrowser] updateModels called with', models.length, 'models');
|
|
this.models = models;
|
|
this.applyFilters(this.currentFilters);
|
|
}
|
|
|
|
/**
|
|
* Applies filters and sorting to the model list
|
|
* @param {Object} filters - Filter configuration
|
|
*/
|
|
applyFilters(filters = {}) {
|
|
this.currentFilters = filters;
|
|
|
|
// Start with full model list
|
|
let filtered = [...this.models];
|
|
|
|
// Apply search filter
|
|
if (filters.search) {
|
|
const searchLower = filters.search.toLowerCase();
|
|
filtered = filtered.filter(model => {
|
|
const name = (model.info?.model?.name || '').toLowerCase();
|
|
const version = (model.info?.name || '').toLowerCase();
|
|
const path = model.path.toLowerCase();
|
|
return name.includes(searchLower) ||
|
|
version.includes(searchLower) ||
|
|
path.includes(searchLower);
|
|
});
|
|
}
|
|
|
|
// Apply folder type filter
|
|
if (filters.type && filters.type !== 'all') {
|
|
filtered = filtered.filter(model => {
|
|
const folderType = this.getFolderType(model.path);
|
|
return folderType === filters.type;
|
|
});
|
|
}
|
|
|
|
// Apply last used filter
|
|
if (filters.lastUsed && filters.lastUsed !== 'all') {
|
|
const now = Date.now();
|
|
filtered = filtered.filter(model => {
|
|
// Check both lastUsed and last_accessed properties (matches V1 behavior)
|
|
const lastUsedValue = model.info?.lastUsed || model.info?.last_accessed;
|
|
|
|
if (filters.lastUsed === 'never') {
|
|
// Show models that have never been used
|
|
return !lastUsedValue;
|
|
}
|
|
|
|
// For time-based filters, model must have been used
|
|
if (!lastUsedValue) return false;
|
|
|
|
const lastUsed = new Date(lastUsedValue).getTime();
|
|
const diffDays = (now - lastUsed) / (1000 * 60 * 60 * 24);
|
|
|
|
switch (filters.lastUsed) {
|
|
case 'today':
|
|
return diffDays < 1;
|
|
case 'week':
|
|
return diffDays < 7;
|
|
case 'month':
|
|
return diffDays < 30;
|
|
default:
|
|
return true;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Apply updates filter
|
|
if (filters.updates && filters.updates !== 'all') {
|
|
filtered = filtered.filter(model => {
|
|
// Use update_available property (matches V1 behavior)
|
|
const hasUpdate = model.info?.update_available || false;
|
|
|
|
if (filters.updates === 'available') {
|
|
// Show only models with updates available
|
|
return hasUpdate;
|
|
} else if (filters.updates === 'none') {
|
|
// Show only models without updates available
|
|
return !hasUpdate;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// Apply sorting
|
|
const sortBy = (filters.sort || 'name').replace(/-desc$/, '');
|
|
const isDescending = (filters.sort || '').endsWith('-desc');
|
|
|
|
filtered.sort((a, b) => {
|
|
let comparison = 0;
|
|
|
|
switch (sortBy) {
|
|
case 'name':
|
|
const nameA = (a.info?.model?.name || this.getFileNameFromPath(a.path)).toLowerCase();
|
|
const nameB = (b.info?.model?.name || this.getFileNameFromPath(b.path)).toLowerCase();
|
|
comparison = nameA.localeCompare(nameB);
|
|
break;
|
|
|
|
case 'lastused':
|
|
// Check both lastUsed and last_accessed properties (matches V1 behavior)
|
|
const lastUsedA = (a.info?.lastUsed || a.info?.last_accessed) ? new Date(a.info.lastUsed || a.info.last_accessed).getTime() : 0;
|
|
const lastUsedB = (b.info?.lastUsed || b.info?.last_accessed) ? new Date(b.info.lastUsed || b.info.last_accessed).getTime() : 0;
|
|
comparison = lastUsedB - lastUsedA; // Most recent first
|
|
break;
|
|
|
|
case 'size':
|
|
const sizeA = a.info?.file_size || 0;
|
|
const sizeB = b.info?.file_size || 0;
|
|
comparison = sizeB - sizeA; // Largest first
|
|
break;
|
|
|
|
case 'type':
|
|
const typeA = this.getFolderType(a.path);
|
|
const typeB = this.getFolderType(b.path);
|
|
comparison = typeA.localeCompare(typeB);
|
|
break;
|
|
}
|
|
|
|
return isDescending ? -comparison : comparison;
|
|
});
|
|
|
|
log.debug('After filtering:', filtered.length, 'models (from', this.models.length, 'total)');
|
|
|
|
this.filteredModels = filtered;
|
|
this.renderList();
|
|
}
|
|
|
|
/**
|
|
* Renders or re-renders the model list
|
|
*/
|
|
/**
|
|
* Renders the model list (either flat or hierarchical)
|
|
*/
|
|
renderList() {
|
|
const startTime = performance.now();
|
|
|
|
// Clear existing items (except empty message)
|
|
const items = this.listContainer.querySelectorAll('.model-browser-item, .model-folder-item');
|
|
items.forEach(item => item.remove());
|
|
|
|
// Update count
|
|
if (this.countElement) {
|
|
const count = this.filteredModels.length;
|
|
this.countElement.textContent = `${count} model${count !== 1 ? 's' : ''}`;
|
|
}
|
|
|
|
// Show empty message if no models
|
|
if (this.filteredModels.length === 0) {
|
|
this.emptyMessage.style.display = 'block';
|
|
return;
|
|
} else {
|
|
this.emptyMessage.style.display = 'none';
|
|
}
|
|
|
|
if (this.showHierarchy) {
|
|
// Build and render hierarchical view
|
|
this.renderHierarchical();
|
|
} else {
|
|
// Render flat list
|
|
this.renderFlat();
|
|
}
|
|
|
|
// Reset highlighted index if out of range
|
|
if (this.highlightedIndex >= this.filteredModels.length) {
|
|
this.highlightedIndex = this.filteredModels.length - 1;
|
|
}
|
|
|
|
const renderTime = performance.now() - startTime;
|
|
log.debug(`Rendered ${this.filteredModels.length} models in ${renderTime.toFixed(2)}ms`);
|
|
}
|
|
|
|
/**
|
|
* Renders models in a flat list
|
|
*/
|
|
/**
|
|
* Renders models in flat list view
|
|
* Uses batch rendering for better performance with large lists
|
|
*/
|
|
renderFlat() {
|
|
const BATCH_SIZE = 50; // Render 50 items at a time
|
|
let currentIndex = 0;
|
|
|
|
const renderBatch = () => {
|
|
const endIndex = Math.min(currentIndex + BATCH_SIZE, this.filteredModels.length);
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
for (let i = currentIndex; i < endIndex; i++) {
|
|
const item = this.createModelItem(this.filteredModels[i], i);
|
|
fragment.appendChild(item);
|
|
}
|
|
|
|
this.listContainer.appendChild(fragment);
|
|
currentIndex = endIndex;
|
|
|
|
if (currentIndex < this.filteredModels.length) {
|
|
requestAnimationFrame(renderBatch);
|
|
}
|
|
};
|
|
|
|
renderBatch();
|
|
}
|
|
|
|
/**
|
|
* Renders models in hierarchical folder structure
|
|
*/
|
|
renderHierarchical() {
|
|
const hierarchy = this.buildFolderHierarchy(this.filteredModels);
|
|
this.renderFolderNode(hierarchy, this.listContainer, 0);
|
|
}
|
|
|
|
/**
|
|
* Builds a folder hierarchy from model list
|
|
* @param {Array} models - List of models
|
|
* @returns {Object} Hierarchy tree
|
|
*/
|
|
buildFolderHierarchy(models) {
|
|
const root = { children: {}, models: [] };
|
|
|
|
models.forEach(model => {
|
|
// Extract relative path after base folder (checkpoints, loras, etc.)
|
|
const parts = this.getRelativePathParts(model.path);
|
|
|
|
if (parts.length === 0) {
|
|
// No subdirectories, add to root
|
|
root.models.push(model);
|
|
} else {
|
|
// Navigate/create folder structure
|
|
let current = root;
|
|
const folderPath = parts.slice(0, -1); // All but filename
|
|
|
|
folderPath.forEach(part => {
|
|
if (!current.children[part]) {
|
|
current.children[part] = { children: {}, models: [], name: part };
|
|
}
|
|
current = current.children[part];
|
|
});
|
|
|
|
// Add model to deepest folder
|
|
current.models.push(model);
|
|
}
|
|
});
|
|
|
|
return root;
|
|
}
|
|
|
|
/**
|
|
* Gets path parts relative to base folder
|
|
* @param {string} fullPath - Full model path
|
|
* @returns {Array<string>} Path parts after base folder
|
|
*/
|
|
getRelativePathParts(fullPath) {
|
|
// Normalize path separators
|
|
const normalized = fullPath.replace(/\\/g, '/');
|
|
|
|
// Find the base folder (checkpoints, loras, etc.)
|
|
const baseFolders = ['checkpoints', 'loras', 'embeddings', 'vae', 'controlnet', 'upscale_models', 'clip_vision'];
|
|
let baseIndex = -1;
|
|
let baseFolder = '';
|
|
|
|
for (const folder of baseFolders) {
|
|
const index = normalized.toLowerCase().indexOf(`/${folder}/`);
|
|
if (index !== -1) {
|
|
baseIndex = index + folder.length + 2; // After "/folder/"
|
|
baseFolder = folder;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (baseIndex === -1) {
|
|
// No base folder found, use full path
|
|
return normalized.split('/').filter(p => p);
|
|
}
|
|
|
|
// Get path after base folder
|
|
const relativePath = normalized.substring(baseIndex);
|
|
return relativePath.split('/').filter(p => p);
|
|
}
|
|
|
|
/**
|
|
* Renders a folder node and its contents
|
|
* @param {Object} node - Folder node
|
|
* @param {HTMLElement} container - Container element
|
|
* @param {number} depth - Nesting depth
|
|
* @param {string} fullPath - Full path to this node
|
|
*/
|
|
renderFolderNode(node, container, depth, fullPath = '') {
|
|
// Render models at this level first
|
|
node.models.forEach(model => {
|
|
const item = this.createModelItem(model, -1, depth);
|
|
container.appendChild(item);
|
|
});
|
|
|
|
// Render child folders
|
|
Object.keys(node.children).sort().forEach(folderName => {
|
|
const childNode = node.children[folderName];
|
|
const childPath = fullPath ? `${fullPath}/${folderName}` : folderName;
|
|
|
|
// Create folder item
|
|
const folderItem = this.createFolderItem(folderName, childNode, childPath, depth);
|
|
container.appendChild(folderItem);
|
|
|
|
// If expanded, render children
|
|
if (this.expandedFolders.has(childPath) || this.defaultExpanded) {
|
|
if (!this.expandedFolders.has(childPath) && this.defaultExpanded) {
|
|
this.expandedFolders.add(childPath);
|
|
}
|
|
this.renderFolderNode(childNode, container, depth + 1, childPath);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Creates a folder item element
|
|
* @param {string} name - Folder name
|
|
* @param {Object} node - Folder node
|
|
* @param {string} fullPath - Full path to folder
|
|
* @param {number} depth - Nesting depth
|
|
* @returns {HTMLElement} Folder element
|
|
*/
|
|
createFolderItem(name, node, fullPath, depth) {
|
|
const isExpanded = this.expandedFolders.has(fullPath);
|
|
const modelCount = this.countModelsInNode(node);
|
|
|
|
const item = document.createElement('div');
|
|
item.className = 'model-folder-item';
|
|
item.dataset.folderPath = fullPath;
|
|
item.style.cssText = `
|
|
padding: 8px 12px;
|
|
padding-left: ${12 + depth * 20}px;
|
|
background: #252525;
|
|
border-bottom: 1px solid #333;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
user-select: none;
|
|
`;
|
|
|
|
// Expand/collapse icon
|
|
const icon = document.createElement('span');
|
|
icon.className = 'folder-icon';
|
|
icon.textContent = isExpanded ? '▼' : '▶';
|
|
icon.style.cssText = `
|
|
font-size: 10px;
|
|
color: #888;
|
|
width: 12px;
|
|
transition: transform 0.2s;
|
|
`;
|
|
|
|
// Folder icon
|
|
const folderIcon = document.createElement('span');
|
|
folderIcon.textContent = isExpanded ? '📂' : '📁';
|
|
folderIcon.style.cssText = `
|
|
font-size: 14px;
|
|
`;
|
|
|
|
// Folder name
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.textContent = name;
|
|
nameSpan.style.cssText = `
|
|
flex: 1;
|
|
color: #ddd;
|
|
font-weight: 500;
|
|
`;
|
|
|
|
// Model count badge
|
|
const badge = document.createElement('span');
|
|
badge.textContent = modelCount;
|
|
badge.style.cssText = `
|
|
background: #444;
|
|
color: #aaa;
|
|
padding: 2px 8px;
|
|
border-radius: 10px;
|
|
font-size: 11px;
|
|
`;
|
|
|
|
item.appendChild(icon);
|
|
item.appendChild(folderIcon);
|
|
item.appendChild(nameSpan);
|
|
item.appendChild(badge);
|
|
|
|
// Click handler to toggle expansion
|
|
item.addEventListener('click', () => {
|
|
this.toggleFolder(fullPath);
|
|
});
|
|
|
|
// Hover effects
|
|
item.addEventListener('mouseenter', () => {
|
|
item.style.background = '#2d2d2d';
|
|
});
|
|
|
|
item.addEventListener('mouseleave', () => {
|
|
item.style.background = '#252525';
|
|
});
|
|
|
|
return item;
|
|
}
|
|
|
|
/**
|
|
* Counts total models in a node and its children
|
|
* @param {Object} node - Folder node
|
|
* @returns {number} Total model count
|
|
*/
|
|
countModelsInNode(node) {
|
|
let count = node.models.length;
|
|
Object.values(node.children).forEach(child => {
|
|
count += this.countModelsInNode(child);
|
|
});
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* Toggles a folder's expansion state
|
|
* @param {string} folderPath - Folder path
|
|
*/
|
|
toggleFolder(folderPath) {
|
|
if (this.expandedFolders.has(folderPath)) {
|
|
this.expandedFolders.delete(folderPath);
|
|
} else {
|
|
this.expandedFolders.add(folderPath);
|
|
}
|
|
this.renderList();
|
|
}
|
|
|
|
/**
|
|
* Expands all folders in the hierarchy
|
|
*/
|
|
expandAllFolders() {
|
|
const collectFolderPaths = (node, parentPath = '') => {
|
|
Object.keys(node.children).forEach(folderName => {
|
|
const folderPath = parentPath ? `${parentPath}/${folderName}` : folderName;
|
|
this.expandedFolders.add(folderPath);
|
|
collectFolderPaths(node.children[folderName], folderPath);
|
|
});
|
|
};
|
|
|
|
const hierarchy = this.buildFolderHierarchy(this.filteredModels);
|
|
collectFolderPaths(hierarchy);
|
|
}
|
|
|
|
/**
|
|
* Gets the currently selected model hash(es)
|
|
* @returns {string|Array<string>} Selected hash or array of hashes
|
|
*/
|
|
getSelected() {
|
|
if (this.options.selectionMode === 'single') {
|
|
return this.selectedModels.size > 0
|
|
? Array.from(this.selectedModels)[0]
|
|
: null;
|
|
} else {
|
|
return Array.from(this.selectedModels);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clears all selections
|
|
*/
|
|
clearSelection() {
|
|
this.selectedModels.clear();
|
|
this.updateSelectionVisuals();
|
|
}
|
|
|
|
/**
|
|
* Sets selection programmatically
|
|
* @param {string|Array<string>} hashOrHashes - Hash or array of hashes to select
|
|
*/
|
|
setSelected(hashOrHashes) {
|
|
this.selectedModels.clear();
|
|
|
|
const hashes = Array.isArray(hashOrHashes) ? hashOrHashes : [hashOrHashes];
|
|
|
|
hashes.forEach(hash => {
|
|
if (this.options.selectionMode === 'single' && this.selectedModels.size > 0) {
|
|
return; // Only allow one selection in single mode
|
|
}
|
|
this.selectedModels.add(hash);
|
|
});
|
|
|
|
this.updateSelectionVisuals();
|
|
}
|
|
|
|
/**
|
|
* Destroys the browser and cleans up event listeners
|
|
*/
|
|
destroy() {
|
|
if (this.container) {
|
|
this.container.removeEventListener('keydown', this.handleKeyDown);
|
|
this.container.remove();
|
|
}
|
|
|
|
this.models = [];
|
|
this.filteredModels = [];
|
|
this.selectedModels.clear();
|
|
this.container = null;
|
|
this.listContainer = null;
|
|
}
|
|
|
|
// ==================== Utility Methods ====================
|
|
|
|
/**
|
|
* Gets icon for model type
|
|
* @param {Object} info - Model info object
|
|
* @returns {string} Icon emoji
|
|
*/
|
|
getModelTypeIcon(info) {
|
|
if (!info || !info.model) return '📦';
|
|
|
|
const type = info.model.type || info.model_type;
|
|
|
|
switch (type?.toLowerCase()) {
|
|
case 'checkpoint':
|
|
return '🎨';
|
|
case 'lora':
|
|
case 'lycoris':
|
|
return '🎯';
|
|
case 'textualinversion':
|
|
case 'embedding':
|
|
return '💬';
|
|
case 'vae':
|
|
return '🔧';
|
|
case 'controlnet':
|
|
return '🎮';
|
|
default:
|
|
return '📦';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extracts filename from full path
|
|
* @param {string} path - Full file path
|
|
* @returns {string} Filename
|
|
*/
|
|
getFileNameFromPath(path) {
|
|
return path.split('/').pop().split('\\').pop();
|
|
}
|
|
|
|
/**
|
|
* Gets folder type from path
|
|
* @param {string} path - File path
|
|
* @returns {string} Folder type
|
|
*/
|
|
getFolderType(path) {
|
|
const pathLower = path.toLowerCase();
|
|
|
|
if (pathLower.includes('/checkpoints/') || pathLower.includes('\\checkpoints\\')) {
|
|
return 'checkpoints';
|
|
} else if (pathLower.includes('/loras/') || pathLower.includes('\\loras\\')) {
|
|
return 'loras';
|
|
} else if (pathLower.includes('/embeddings/') || pathLower.includes('\\embeddings\\')) {
|
|
return 'embeddings';
|
|
} else if (pathLower.includes('/vae/') || pathLower.includes('\\vae\\')) {
|
|
return 'vae';
|
|
} else if (pathLower.includes('/controlnet/') || pathLower.includes('\\controlnet\\')) {
|
|
return 'controlnet';
|
|
}
|
|
|
|
return 'other';
|
|
}
|
|
|
|
/**
|
|
* Formats file size for display
|
|
* @param {number} bytes - File size in bytes
|
|
* @returns {string} Formatted size
|
|
*/
|
|
formatFileSize(bytes) {
|
|
if (!bytes) return '0 B';
|
|
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
/**
|
|
* Formats last used timestamp for display
|
|
* @param {string|number} timestamp - Last used timestamp
|
|
* @returns {string} Formatted relative time
|
|
*/
|
|
formatLastUsed(timestamp) {
|
|
if (!timestamp) return 'Never';
|
|
|
|
const now = Date.now();
|
|
const lastUsed = new Date(timestamp).getTime();
|
|
const diffMs = now - lastUsed;
|
|
|
|
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffMinutes < 1) return 'Just now';
|
|
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
if (diffDays === 1) return 'Yesterday';
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
|
|
if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`;
|
|
return `${Math.floor(diffDays / 365)}y ago`;
|
|
}
|
|
}
|
|
|
|
// Default export
|
|
export default ModelBrowser;
|