diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index d8a0ddcf..5c7493ed 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -312,6 +312,8 @@ export async function showModelModal(model, modelType) { ].join('\n') : ''; const hasUpdateAvailable = Boolean(modelWithFullData.update_available); + const updateAvailabilityState = { hasUpdateAvailable }; + const updateBadgeTooltip = translate('modelCard.badges.updateAvailable', {}, 'Update available'); // Prepare LoRA specific data with complete civitai data const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ? @@ -521,6 +523,82 @@ export async function showModelModal(model, modelType) { `; + function updateVersionsTabBadge(hasUpdate) { + const modalElement = document.getElementById(modalId); + if (!modalElement) return; + + const tabButton = modalElement.querySelector('.tab-btn[data-tab="versions"]'); + if (!tabButton) return; + + tabButton.classList.toggle('tab-btn--has-update', hasUpdate); + + let badge = tabButton.querySelector('.tab-badge--update'); + if (hasUpdate) { + if (!badge) { + badge = document.createElement('span'); + badge.className = 'tab-badge tab-badge--update'; + badge.textContent = versionsBadgeLabel; + badge.title = updateBadgeTooltip; + tabButton.appendChild(badge); + } else { + badge.textContent = versionsBadgeLabel; + badge.title = updateBadgeTooltip; + } + } else if (badge) { + badge.remove(); + } + } + + function updateCardUpdateAvailability(hasUpdate) { + const filePath = modelWithFullData.file_path; + if (!filePath) return; + + let updatedViaScroller = false; + if (state.virtualScroller?.updateSingleItem) { + updatedViaScroller = state.virtualScroller.updateSingleItem(filePath, { + update_available: hasUpdate, + }); + } + + if (updatedViaScroller) { + return; + } + + const escapedPath = window.CSS && typeof window.CSS.escape === 'function' + ? window.CSS.escape(filePath) + : filePath.replace(/["\\]/g, '\\$&'); + const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`); + if (!card) return; + + card.dataset.update_available = hasUpdate ? 'true' : 'false'; + card.classList.toggle('has-update', hasUpdate); + + const headerInfo = card.querySelector('.card-header-info'); + if (!headerInfo) return; + + let badge = headerInfo.querySelector('.model-update-badge'); + if (hasUpdate) { + if (!badge) { + badge = document.createElement('span'); + badge.className = 'model-update-badge'; + badge.title = updateBadgeTooltip; + badge.textContent = versionsBadgeLabel; + headerInfo.appendChild(badge); + } + } else if (badge) { + badge.remove(); + } + } + + function handleUpdateStatusChange(hasUpdate) { + if (updateAvailabilityState.hasUpdateAvailable === hasUpdate) { + return; + } + updateAvailabilityState.hasUpdateAvailable = hasUpdate; + updateVersionsTabBadge(hasUpdate); + updateCardUpdateAvailability(hasUpdate); + } + let showcaseCleanup; const onCloseCallback = function() { @@ -542,11 +620,14 @@ export async function showModelModal(model, modelType) { if (activeModalElement) { activeModalElement.dataset.filePath = modelWithFullData.file_path || ''; } + updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable); const versionsTabController = initVersionsTab({ modalId, modelType, modelId: civitaiModelId, currentVersionId: civitaiVersionId, + currentBaseModel: modelWithFullData.base_model, + onUpdateStatusChange: handleUpdateStatusChange, }); setupEditableFields(modelWithFullData.file_path, modelType); showcaseCleanup = setupShowcaseScroll(modalId); diff --git a/static/js/components/shared/ModelVersionsTab.js b/static/js/components/shared/ModelVersionsTab.js index cf9a402a..dd4c12da 100644 --- a/static/js/components/shared/ModelVersionsTab.js +++ b/static/js/components/shared/ModelVersionsTab.js @@ -228,6 +228,64 @@ function getCurrentVersionBaseModel(record, versionId) { }; } +function resolveUpdateAvailability(record, baseModel, currentVersionId) { + if (!record) { + return false; + } + + const strategy = state?.global?.settings?.update_flag_strategy; + const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE; + + if (!sameBaseMode) { + return Boolean(record?.hasUpdate); + } + + const normalizedBase = normalizeBaseModelName(baseModel); + if (!normalizedBase || !Array.isArray(record.versions)) { + return false; + } + + const normalizedCurrentVersionId = + typeof currentVersionId === 'number' + ? currentVersionId + : currentVersionId + ? Number(currentVersionId) + : null; + + let threshold = null; + for (const version of record.versions) { + if (!version.isInLibrary) { + continue; + } + const versionBase = normalizeBaseModelName(version.baseModel); + if (versionBase !== normalizedBase) { + continue; + } + if (threshold === null || version.versionId > threshold) { + threshold = version.versionId; + } + } + + if (threshold === null) { + threshold = normalizedCurrentVersionId; + } + + if (threshold === null) { + return false; + } + + return record.versions.some(version => { + if (version.isInLibrary || version.shouldIgnore) { + return false; + } + const versionBase = normalizeBaseModelName(version.baseModel); + if (versionBase !== normalizedBase) { + return false; + } + return typeof version.versionId === 'number' && version.versionId > threshold; + }); +} + function getAutoplaySetting() { try { return Boolean(state?.global?.settings?.autoplay_on_hover); @@ -490,6 +548,8 @@ export function initVersionsTab({ modelType, modelId, currentVersionId, + currentBaseModel, + onUpdateStatusChange, }) { const pane = document.querySelector(`#${modalId} #versions-tab`); const container = pane ? pane.querySelector('.model-versions-tab') : null; @@ -513,6 +573,10 @@ export function initVersionsTab({ record: null, }; + const updateStatusChangeHandler = + typeof onUpdateStatusChange === 'function' ? onUpdateStatusChange : null; + let lastNotifiedUpdateState = null; + let displayMode = getDefaultDisplayMode(); let apiClient; @@ -538,77 +602,77 @@ export function initVersionsTab({ `; } -function render(record) { - controller.record = record; - controller.hasLoaded = true; + function render(record) { + controller.record = record; + controller.hasLoaded = true; - if (!record || !Array.isArray(record.versions) || record.versions.length === 0) { - renderEmptyState(container); - return; - } - - const latestLibraryVersionId = getLatestLibraryVersionId(record); - const { normalized: currentBaseModelNormalized, raw: currentBaseModelLabel } = - getCurrentVersionBaseModel(record, normalizedCurrentVersionId); - const isFilteringActive = - displayMode === DISPLAY_FILTER_MODES.SAME_BASE && - Boolean(currentBaseModelNormalized); - - const sortedVersions = [...record.versions].sort( - (a, b) => Number(b.versionId) - Number(a.versionId) - ); - - const filteredVersions = sortedVersions.filter(version => { - if (!isFilteringActive) { - return true; + if (!record || !Array.isArray(record.versions) || record.versions.length === 0) { + renderEmptyState(container); + return; } - return normalizeBaseModelName(version.baseModel) === currentBaseModelNormalized; - }); - const dividerThresholdVersionId = (() => { - if (!isFilteringActive) { - return latestLibraryVersionId; - } - const baseLocalVersionIds = record.versions - .filter( - version => - version.isInLibrary && - normalizeBaseModelName(version.baseModel) === currentBaseModelNormalized && - typeof version.versionId === 'number' - ) - .map(version => version.versionId); - if (!baseLocalVersionIds.length) { - return null; - } - return Math.max(...baseLocalVersionIds); - })(); + const latestLibraryVersionId = getLatestLibraryVersionId(record); + const { normalized: currentBaseModelNormalized, raw: currentBaseModelLabel } = + getCurrentVersionBaseModel(record, normalizedCurrentVersionId); + const isFilteringActive = + displayMode === DISPLAY_FILTER_MODES.SAME_BASE && + Boolean(currentBaseModelNormalized); - let dividerInserted = false; + const sortedVersions = [...record.versions].sort( + (a, b) => Number(b.versionId) - Number(a.versionId) + ); - const rowsMarkup = filteredVersions - .map(version => { - let markup = ''; - if ( - !dividerInserted && - typeof dividerThresholdVersionId === 'number' && - !(version.versionId > dividerThresholdVersionId) - ) { - dividerInserted = true; - markup += '
'; + const filteredVersions = sortedVersions.filter(version => { + if (!isFilteringActive) { + return true; } - markup += renderRow(version, { - latestLibraryVersionId: dividerThresholdVersionId, - currentVersionId: normalizedCurrentVersionId, - modelId: record?.modelId ?? modelId, - }); - return markup; - }) - .join(''); + return normalizeBaseModelName(version.baseModel) === currentBaseModelNormalized; + }); - const listContent = - rowsMarkup || renderFilteredEmptyState(currentBaseModelLabel); + const dividerThresholdVersionId = (() => { + if (!isFilteringActive) { + return latestLibraryVersionId; + } + const baseLocalVersionIds = record.versions + .filter( + version => + version.isInLibrary && + normalizeBaseModelName(version.baseModel) === currentBaseModelNormalized && + typeof version.versionId === 'number' + ) + .map(version => version.versionId); + if (!baseLocalVersionIds.length) { + return null; + } + return Math.max(...baseLocalVersionIds); + })(); - container.innerHTML = ` + let dividerInserted = false; + + const rowsMarkup = filteredVersions + .map(version => { + let markup = ''; + if ( + !dividerInserted && + typeof dividerThresholdVersionId === 'number' && + !(version.versionId > dividerThresholdVersionId) + ) { + dividerInserted = true; + markup += ''; + } + markup += renderRow(version, { + latestLibraryVersionId: dividerThresholdVersionId, + currentVersionId: normalizedCurrentVersionId, + modelId: record?.modelId ?? modelId, + }); + return markup; + }) + .join(''); + + const listContent = + rowsMarkup || renderFilteredEmptyState(currentBaseModelLabel); + + container.innerHTML = ` ${renderToolbar(record, { displayMode, isFilteringActive, @@ -618,8 +682,20 @@ function render(record) { `; - setupMediaHoverInteractions(container); -} + setupMediaHoverInteractions(container); + + if (updateStatusChangeHandler) { + const resolvedFlag = resolveUpdateAvailability( + record, + currentBaseModel, + normalizedCurrentVersionId + ); + if (resolvedFlag !== lastNotifiedUpdateState) { + lastNotifiedUpdateState = resolvedFlag; + updateStatusChangeHandler(resolvedFlag, record); + } + } + } async function loadVersions({ forceRefresh = false, eager = false } = {}) { if (controller.isLoading) {