feat(model-modal): add dynamic update availability indicators, see #715

- Add update badge to versions tab button when model has updates
- Sync update status between modal and model cards in gallery
- Pass `onUpdateStatusChange` callback to versions tab for real-time updates
- Introduce `updateAvailabilityState` to track update status changes
- Improve user awareness of available model updates across UI components
This commit is contained in:
Will Miao
2025-12-06 09:43:15 +08:00
parent 83f379df33
commit 6efe59bd9e
2 changed files with 222 additions and 65 deletions

View File

@@ -312,6 +312,8 @@ export async function showModelModal(model, modelType) {
].join('\n') ].join('\n')
: ''; : '';
const hasUpdateAvailable = Boolean(modelWithFullData.update_available); 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 // Prepare LoRA specific data with complete civitai data
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ? const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ?
@@ -521,6 +523,82 @@ export async function showModelModal(model, modelType) {
</div> </div>
`; `;
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; let showcaseCleanup;
const onCloseCallback = function() { const onCloseCallback = function() {
@@ -542,11 +620,14 @@ export async function showModelModal(model, modelType) {
if (activeModalElement) { if (activeModalElement) {
activeModalElement.dataset.filePath = modelWithFullData.file_path || ''; activeModalElement.dataset.filePath = modelWithFullData.file_path || '';
} }
updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable);
const versionsTabController = initVersionsTab({ const versionsTabController = initVersionsTab({
modalId, modalId,
modelType, modelType,
modelId: civitaiModelId, modelId: civitaiModelId,
currentVersionId: civitaiVersionId, currentVersionId: civitaiVersionId,
currentBaseModel: modelWithFullData.base_model,
onUpdateStatusChange: handleUpdateStatusChange,
}); });
setupEditableFields(modelWithFullData.file_path, modelType); setupEditableFields(modelWithFullData.file_path, modelType);
showcaseCleanup = setupShowcaseScroll(modalId); showcaseCleanup = setupShowcaseScroll(modalId);

View File

@@ -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() { function getAutoplaySetting() {
try { try {
return Boolean(state?.global?.settings?.autoplay_on_hover); return Boolean(state?.global?.settings?.autoplay_on_hover);
@@ -490,6 +548,8 @@ export function initVersionsTab({
modelType, modelType,
modelId, modelId,
currentVersionId, currentVersionId,
currentBaseModel,
onUpdateStatusChange,
}) { }) {
const pane = document.querySelector(`#${modalId} #versions-tab`); const pane = document.querySelector(`#${modalId} #versions-tab`);
const container = pane ? pane.querySelector('.model-versions-tab') : null; const container = pane ? pane.querySelector('.model-versions-tab') : null;
@@ -513,6 +573,10 @@ export function initVersionsTab({
record: null, record: null,
}; };
const updateStatusChangeHandler =
typeof onUpdateStatusChange === 'function' ? onUpdateStatusChange : null;
let lastNotifiedUpdateState = null;
let displayMode = getDefaultDisplayMode(); let displayMode = getDefaultDisplayMode();
let apiClient; let apiClient;
@@ -538,77 +602,77 @@ export function initVersionsTab({
`; `;
} }
function render(record) { function render(record) {
controller.record = record; controller.record = record;
controller.hasLoaded = true; controller.hasLoaded = true;
if (!record || !Array.isArray(record.versions) || record.versions.length === 0) { if (!record || !Array.isArray(record.versions) || record.versions.length === 0) {
renderEmptyState(container); renderEmptyState(container);
return; 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;
} }
return normalizeBaseModelName(version.baseModel) === currentBaseModelNormalized;
});
const dividerThresholdVersionId = (() => { const latestLibraryVersionId = getLatestLibraryVersionId(record);
if (!isFilteringActive) { const { normalized: currentBaseModelNormalized, raw: currentBaseModelLabel } =
return latestLibraryVersionId; getCurrentVersionBaseModel(record, normalizedCurrentVersionId);
} const isFilteringActive =
const baseLocalVersionIds = record.versions displayMode === DISPLAY_FILTER_MODES.SAME_BASE &&
.filter( Boolean(currentBaseModelNormalized);
version =>
version.isInLibrary &&
normalizeBaseModelName(version.baseModel) === currentBaseModelNormalized &&
typeof version.versionId === 'number'
)
.map(version => version.versionId);
if (!baseLocalVersionIds.length) {
return null;
}
return Math.max(...baseLocalVersionIds);
})();
let dividerInserted = false; const sortedVersions = [...record.versions].sort(
(a, b) => Number(b.versionId) - Number(a.versionId)
);
const rowsMarkup = filteredVersions const filteredVersions = sortedVersions.filter(version => {
.map(version => { if (!isFilteringActive) {
let markup = ''; return true;
if (
!dividerInserted &&
typeof dividerThresholdVersionId === 'number' &&
!(version.versionId > dividerThresholdVersionId)
) {
dividerInserted = true;
markup += '<div class="version-divider" role="presentation"></div>';
} }
markup += renderRow(version, { return normalizeBaseModelName(version.baseModel) === currentBaseModelNormalized;
latestLibraryVersionId: dividerThresholdVersionId, });
currentVersionId: normalizedCurrentVersionId,
modelId: record?.modelId ?? modelId,
});
return markup;
})
.join('');
const listContent = const dividerThresholdVersionId = (() => {
rowsMarkup || renderFilteredEmptyState(currentBaseModelLabel); 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 += '<div class="version-divider" role="presentation"></div>';
}
markup += renderRow(version, {
latestLibraryVersionId: dividerThresholdVersionId,
currentVersionId: normalizedCurrentVersionId,
modelId: record?.modelId ?? modelId,
});
return markup;
})
.join('');
const listContent =
rowsMarkup || renderFilteredEmptyState(currentBaseModelLabel);
container.innerHTML = `
${renderToolbar(record, { ${renderToolbar(record, {
displayMode, displayMode,
isFilteringActive, isFilteringActive,
@@ -618,8 +682,20 @@ function render(record) {
</div> </div>
`; `;
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 } = {}) { async function loadVersions({ forceRefresh = false, eager = false } = {}) {
if (controller.isLoading) { if (controller.isLoading) {