mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Add CSS classes and JavaScript logic for the base filter toggle button in the versions toolbar. The filter allows users to switch between showing all versions or only versions matching the current base model. Includes styling for different states (active, hover, disabled) and accessibility features like screen reader support.
1024 lines
34 KiB
JavaScript
1024 lines
34 KiB
JavaScript
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
|
import { downloadManager } from '../../managers/DownloadManager.js';
|
|
import { modalManager } from '../../managers/ModalManager.js';
|
|
import { showToast } from '../../utils/uiHelpers.js';
|
|
import { translate } from '../../utils/i18nHelpers.js';
|
|
import { state } from '../../state/index.js';
|
|
import { formatFileSize } from './utils.js';
|
|
|
|
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
|
|
|
|
function buildCivitaiVersionUrl(modelId, versionId) {
|
|
if (modelId == null || versionId == null) {
|
|
return null;
|
|
}
|
|
const normalizedModelId = String(modelId).trim();
|
|
const normalizedVersionId = String(versionId).trim();
|
|
if (!normalizedModelId || !normalizedVersionId) {
|
|
return null;
|
|
}
|
|
const encodedModelId = encodeURIComponent(normalizedModelId);
|
|
const encodedVersionId = encodeURIComponent(normalizedVersionId);
|
|
return `https://civitai.com/models/${encodedModelId}?modelVersionId=${encodedVersionId}`;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
if (value == null) {
|
|
return '';
|
|
}
|
|
return String(value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function extractExtension(value) {
|
|
if (value == null) {
|
|
return '';
|
|
}
|
|
const targets = [];
|
|
const stringValue = String(value);
|
|
if (stringValue) {
|
|
targets.push(stringValue);
|
|
stringValue.split(/[?&=]/).forEach(fragment => {
|
|
if (fragment) {
|
|
targets.push(fragment);
|
|
}
|
|
});
|
|
}
|
|
|
|
for (const target of targets) {
|
|
let candidate = target;
|
|
try {
|
|
candidate = decodeURIComponent(candidate);
|
|
} catch (error) {
|
|
// ignoring malformed sequences, fallback to raw value
|
|
}
|
|
const lastDot = candidate.lastIndexOf('.');
|
|
if (lastDot === -1) {
|
|
continue;
|
|
}
|
|
const extension = candidate.slice(lastDot).toLowerCase();
|
|
if (extension.includes('/') || extension.includes('\\')) {
|
|
continue;
|
|
}
|
|
return extension;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function isVideoUrl(url) {
|
|
if (!url || typeof url !== 'string') {
|
|
return false;
|
|
}
|
|
|
|
const candidates = new Set();
|
|
const addCandidate = value => {
|
|
if (value == null) {
|
|
return;
|
|
}
|
|
const stringValue = String(value);
|
|
if (!stringValue) {
|
|
return;
|
|
}
|
|
candidates.add(stringValue);
|
|
};
|
|
|
|
addCandidate(url);
|
|
|
|
try {
|
|
const parsed = new URL(url, window.location.origin);
|
|
addCandidate(parsed.pathname);
|
|
parsed.searchParams.forEach(value => addCandidate(value));
|
|
} catch (error) {
|
|
// ignore parse errors and rely on fallbacks below
|
|
}
|
|
|
|
for (const candidate of candidates) {
|
|
const extension = extractExtension(candidate);
|
|
if (extension && VIDEO_EXTENSIONS.includes(extension)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function formatDateLabel(value) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
const parsed = new Date(value);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return null;
|
|
}
|
|
return parsed.toLocaleDateString(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
});
|
|
}
|
|
|
|
function buildMetaMarkup(version) {
|
|
const segments = [];
|
|
if (version.baseModel) {
|
|
segments.push(
|
|
`<span class="version-meta-primary">${escapeHtml(version.baseModel)}</span>`
|
|
);
|
|
}
|
|
const releaseLabel = formatDateLabel(version.releasedAt);
|
|
if (releaseLabel) {
|
|
segments.push(escapeHtml(releaseLabel));
|
|
}
|
|
if (typeof version.sizeBytes === 'number' && version.sizeBytes > 0) {
|
|
segments.push(escapeHtml(formatFileSize(version.sizeBytes)));
|
|
}
|
|
|
|
if (!segments.length) {
|
|
return escapeHtml(
|
|
translate('modals.model.versions.labels.noDetails', {}, 'No additional details')
|
|
);
|
|
}
|
|
|
|
return segments
|
|
.map(segment => `<span class="version-meta-item">${segment}</span>`)
|
|
.join('<span class="version-meta-separator">•</span>');
|
|
}
|
|
|
|
function buildBadge(label, tone) {
|
|
return `<span class="version-badge version-badge-${tone}">${escapeHtml(label)}</span>`;
|
|
}
|
|
|
|
const DISPLAY_FILTER_MODES = Object.freeze({
|
|
SAME_BASE: 'same_base',
|
|
ANY: 'any',
|
|
});
|
|
|
|
const FILTER_LABEL_KEY = 'modals.model.versions.filters.label';
|
|
const FILTER_STATE_KEYS = {
|
|
[DISPLAY_FILTER_MODES.SAME_BASE]: 'modals.model.versions.filters.state.showSameBase',
|
|
[DISPLAY_FILTER_MODES.ANY]: 'modals.model.versions.filters.state.showAll',
|
|
};
|
|
const FILTER_TOOLTIP_KEYS = {
|
|
[DISPLAY_FILTER_MODES.SAME_BASE]: 'modals.model.versions.filters.tooltip.showAllVersions',
|
|
[DISPLAY_FILTER_MODES.ANY]: 'modals.model.versions.filters.tooltip.showSameBaseVersions',
|
|
};
|
|
|
|
function normalizeBaseModelName(value) {
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
return trimmed.toLowerCase();
|
|
}
|
|
|
|
function getToggleLabelText() {
|
|
return translate(FILTER_LABEL_KEY, {}, 'Base filter');
|
|
}
|
|
|
|
function getToggleStateText(mode) {
|
|
const key = FILTER_STATE_KEYS[mode] || FILTER_STATE_KEYS[DISPLAY_FILTER_MODES.ANY];
|
|
const fallback =
|
|
mode === DISPLAY_FILTER_MODES.SAME_BASE ? 'Same base' : 'All versions';
|
|
return translate(key, {}, fallback);
|
|
}
|
|
|
|
function getToggleTooltipText(mode) {
|
|
const key =
|
|
FILTER_TOOLTIP_KEYS[mode] || FILTER_TOOLTIP_KEYS[DISPLAY_FILTER_MODES.ANY];
|
|
const fallback =
|
|
mode === DISPLAY_FILTER_MODES.SAME_BASE
|
|
? 'Switch to showing all versions'
|
|
: 'Switch to showing only versions with the current base model';
|
|
return translate(key, {}, fallback);
|
|
}
|
|
|
|
function getDefaultDisplayMode() {
|
|
const strategy = state?.global?.settings?.update_flag_strategy;
|
|
return strategy === DISPLAY_FILTER_MODES.SAME_BASE
|
|
? DISPLAY_FILTER_MODES.SAME_BASE
|
|
: DISPLAY_FILTER_MODES.ANY;
|
|
}
|
|
|
|
function getCurrentVersionBaseModel(record, versionId) {
|
|
if (!record || typeof versionId !== 'number' || !Array.isArray(record.versions)) {
|
|
return {
|
|
normalized: null,
|
|
raw: null,
|
|
};
|
|
}
|
|
const currentVersion = record.versions.find(v => v.versionId === versionId);
|
|
if (!currentVersion) {
|
|
return {
|
|
normalized: null,
|
|
raw: null,
|
|
};
|
|
}
|
|
const baseModelRaw = currentVersion.baseModel ?? null;
|
|
return {
|
|
normalized: normalizeBaseModelName(baseModelRaw),
|
|
raw: baseModelRaw,
|
|
};
|
|
}
|
|
|
|
function getAutoplaySetting() {
|
|
try {
|
|
return Boolean(state?.global?.settings?.autoplay_on_hover);
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function renderMediaMarkup(version) {
|
|
if (!version.previewUrl) {
|
|
const placeholderText = translate('modals.model.versions.media.placeholder', {}, 'No preview');
|
|
return `<div class="version-media version-media-placeholder">${escapeHtml(placeholderText)}</div>`;
|
|
}
|
|
|
|
if (isVideoUrl(version.previewUrl)) {
|
|
const autoplayOnHover = getAutoplaySetting();
|
|
return `
|
|
<div class="version-media">
|
|
<video
|
|
src="${escapeHtml(version.previewUrl)}"
|
|
${autoplayOnHover ? '' : 'controls'}
|
|
muted
|
|
loop
|
|
playsinline
|
|
preload="metadata"
|
|
data-autoplay-on-hover="${autoplayOnHover ? 'true' : 'false'}"
|
|
></video>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<div class="version-media">
|
|
<img src="${escapeHtml(version.previewUrl)}" alt="${escapeHtml(version.name || 'preview')}">
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderRow(version, options) {
|
|
const { latestLibraryVersionId, currentVersionId, modelId: parentModelId } = options;
|
|
const isCurrent = currentVersionId && version.versionId === currentVersionId;
|
|
const isNewer =
|
|
typeof latestLibraryVersionId === 'number' &&
|
|
version.versionId > latestLibraryVersionId;
|
|
const badges = [];
|
|
|
|
if (isCurrent) {
|
|
badges.push(buildBadge(translate('modals.model.versions.badges.current', {}, 'Current Version'), 'current'));
|
|
}
|
|
|
|
if (version.isInLibrary) {
|
|
badges.push(buildBadge(translate('modals.model.versions.badges.inLibrary', {}, 'In Library'), 'success'));
|
|
} else if (isNewer && !version.shouldIgnore) {
|
|
badges.push(buildBadge(translate('modals.model.versions.badges.newer', {}, 'Newer Version'), 'info'));
|
|
}
|
|
|
|
if (version.shouldIgnore) {
|
|
badges.push(buildBadge(translate('modals.model.versions.badges.ignored', {}, 'Ignored'), 'muted'));
|
|
}
|
|
|
|
const downloadLabel = translate('modals.model.versions.actions.download', {}, 'Download');
|
|
const deleteLabel = translate('modals.model.versions.actions.delete', {}, 'Delete');
|
|
const ignoreLabel = translate(
|
|
version.shouldIgnore
|
|
? 'modals.model.versions.actions.unignore'
|
|
: 'modals.model.versions.actions.ignore',
|
|
{},
|
|
version.shouldIgnore ? 'Unignore' : 'Ignore'
|
|
);
|
|
|
|
const actions = [];
|
|
if (!version.isInLibrary) {
|
|
actions.push(
|
|
`<button class="version-action version-action-primary" data-version-action="download">${escapeHtml(downloadLabel)}</button>`
|
|
);
|
|
} else if (version.filePath) {
|
|
actions.push(
|
|
`<button class="version-action version-action-danger" data-version-action="delete">${escapeHtml(deleteLabel)}</button>`
|
|
);
|
|
}
|
|
actions.push(
|
|
`<button class="version-action version-action-ghost" data-version-action="toggle-ignore" data-ignore-state="${
|
|
version.shouldIgnore ? 'ignored' : 'active'
|
|
}">${escapeHtml(ignoreLabel)}</button>`
|
|
);
|
|
|
|
const linkTarget = buildCivitaiVersionUrl(
|
|
version.modelId || parentModelId,
|
|
version.versionId
|
|
);
|
|
const civitaiTooltip = translate(
|
|
'modals.model.actions.viewOnCivitai',
|
|
{},
|
|
'View on Civitai'
|
|
);
|
|
|
|
const rowAttributes = [
|
|
`class="model-version-row${isCurrent ? ' is-current' : ''}${linkTarget ? ' is-clickable' : ''}"`,
|
|
`data-version-id="${escapeHtml(version.versionId)}"`,
|
|
];
|
|
if (linkTarget) {
|
|
rowAttributes.push(`data-civitai-url="${escapeHtml(linkTarget)}"`);
|
|
rowAttributes.push(`title="${escapeHtml(civitaiTooltip)}"`);
|
|
}
|
|
|
|
return `
|
|
<div ${rowAttributes.join(' ')}>
|
|
${renderMediaMarkup(version)}
|
|
<div class="version-details">
|
|
<div class="version-title">
|
|
<span class="versions-tab-version-name">${escapeHtml(version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'))}</span>
|
|
</div>
|
|
<div class="version-badges">${badges.join('')}</div>
|
|
<div class="version-meta">
|
|
${buildMetaMarkup(version)}
|
|
</div>
|
|
</div>
|
|
<div class="version-actions">
|
|
${actions.join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function setupMediaHoverInteractions(container) {
|
|
const autoplayOnHover = getAutoplaySetting();
|
|
if (!autoplayOnHover) {
|
|
return;
|
|
}
|
|
container.querySelectorAll('.version-media video').forEach(video => {
|
|
if (video.dataset.autoplayOnHover !== 'true') {
|
|
return;
|
|
}
|
|
const play = () => {
|
|
try {
|
|
video.currentTime = 0;
|
|
const promise = video.play();
|
|
if (promise && typeof promise.catch === 'function') {
|
|
promise.catch(() => {});
|
|
}
|
|
} catch (error) {
|
|
console.debug('Failed to autoplay preview video:', error);
|
|
}
|
|
};
|
|
const stop = () => {
|
|
video.pause();
|
|
video.currentTime = 0;
|
|
};
|
|
video.addEventListener('mouseenter', play);
|
|
video.addEventListener('focus', play);
|
|
video.addEventListener('mouseleave', stop);
|
|
video.addEventListener('blur', stop);
|
|
});
|
|
}
|
|
|
|
function getLatestLibraryVersionId(record) {
|
|
if (!record || !Array.isArray(record.inLibraryVersionIds) || !record.inLibraryVersionIds.length) {
|
|
return null;
|
|
}
|
|
return Math.max(...record.inLibraryVersionIds);
|
|
}
|
|
|
|
function renderToolbar(record, toolbarState = {}) {
|
|
const ignoreText = record.shouldIgnore
|
|
? translate('modals.model.versions.actions.resumeModelUpdates', {}, 'Resume updates for this model')
|
|
: translate('modals.model.versions.actions.ignoreModelUpdates', {}, 'Ignore updates for this model');
|
|
const viewLocalText = translate('modals.model.versions.actions.viewLocalVersions', {}, 'View all local versions');
|
|
const infoText = translate(
|
|
'modals.model.versions.copy',
|
|
{ count: record.versions.length },
|
|
'Track and manage every version of this model in one place.'
|
|
);
|
|
|
|
const displayMode = toolbarState.displayMode || DISPLAY_FILTER_MODES.ANY;
|
|
const toggleLabel = getToggleLabelText();
|
|
const toggleState = getToggleStateText(displayMode);
|
|
const toggleTooltip = getToggleTooltipText(displayMode);
|
|
const filterActive = toolbarState.isFilteringActive ? 'true' : 'false';
|
|
const screenReaderText = [toggleLabel, toggleState].filter(Boolean).join(': ');
|
|
|
|
return `
|
|
<header class="versions-toolbar">
|
|
<div class="versions-toolbar-info">
|
|
<div class="versions-toolbar-info-heading">
|
|
<h3>${translate('modals.model.versions.heading', {}, 'Model versions')}</h3>
|
|
<button class="versions-filter-toggle" data-versions-action="toggle-version-display-mode" type="button" title="${escapeHtml(toggleTooltip)}" aria-label="${escapeHtml(toggleTooltip)}" data-filter-active="${filterActive}" aria-pressed="${filterActive}">
|
|
<i class="fas fa-th-list" aria-hidden="true"></i>
|
|
<span class="sr-only">${escapeHtml(screenReaderText)}</span>
|
|
</button>
|
|
</div>
|
|
<p>${escapeHtml(infoText)}</p>
|
|
</div>
|
|
<div class="versions-toolbar-actions">
|
|
<button class="versions-toolbar-btn versions-toolbar-btn-primary" data-versions-action="toggle-model-ignore">
|
|
${escapeHtml(ignoreText)}
|
|
</button>
|
|
<button class="versions-toolbar-btn versions-toolbar-btn-secondary" data-versions-action="view-local" title="${escapeHtml(translate('modals.model.versions.actions.viewLocalTooltip', {}, 'Coming soon'))}" disabled>
|
|
${escapeHtml(viewLocalText)}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
`;
|
|
}
|
|
|
|
function renderEmptyState(container) {
|
|
const message = translate('modals.model.versions.empty', {}, 'No version history available for this model yet.');
|
|
container.innerHTML = `
|
|
<div class="versions-empty">
|
|
<i class="fas fa-info-circle"></i>
|
|
<p>${escapeHtml(message)}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderFilteredEmptyState(baseModelLabel) {
|
|
const message = translate(
|
|
'modals.model.versions.filters.empty',
|
|
{ baseModel: baseModelLabel },
|
|
'No versions match the current base model filter.'
|
|
);
|
|
return `
|
|
<div class="versions-empty versions-empty-filter">
|
|
<i class="fas fa-info-circle"></i>
|
|
<p>${escapeHtml(message)}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderErrorState(container, message) {
|
|
const fallback = translate('modals.model.versions.error', {}, 'Failed to load versions.');
|
|
container.innerHTML = `
|
|
<div class="versions-error">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
<p>${escapeHtml(message || fallback)}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
export function initVersionsTab({
|
|
modalId,
|
|
modelType,
|
|
modelId,
|
|
currentVersionId,
|
|
}) {
|
|
const pane = document.querySelector(`#${modalId} #versions-tab`);
|
|
const container = pane ? pane.querySelector('.model-versions-tab') : null;
|
|
const normalizedCurrentVersionId =
|
|
typeof currentVersionId === 'number'
|
|
? currentVersionId
|
|
: currentVersionId
|
|
? Number(currentVersionId)
|
|
: null;
|
|
|
|
if (!container) {
|
|
return {
|
|
async load() {},
|
|
async refresh() {},
|
|
};
|
|
}
|
|
|
|
let controller = {
|
|
isLoading: false,
|
|
hasLoaded: false,
|
|
record: null,
|
|
};
|
|
|
|
let displayMode = getDefaultDisplayMode();
|
|
|
|
let apiClient;
|
|
|
|
function ensureClient() {
|
|
if (apiClient) {
|
|
return apiClient;
|
|
}
|
|
try {
|
|
apiClient = getModelApiClient(modelType);
|
|
} catch (error) {
|
|
apiClient = getModelApiClient();
|
|
}
|
|
return apiClient;
|
|
}
|
|
|
|
function showLoading() {
|
|
const loadingText = translate('modals.model.loading.versions', {}, 'Loading versions...');
|
|
container.innerHTML = `
|
|
<div class="versions-loading-state">
|
|
<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(loadingText)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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;
|
|
}
|
|
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);
|
|
})();
|
|
|
|
let dividerInserted = false;
|
|
|
|
const rowsMarkup = filteredVersions
|
|
.map(version => {
|
|
const isNewer =
|
|
typeof latestLibraryVersionId === 'number' &&
|
|
version.versionId > latestLibraryVersionId;
|
|
let markup = '';
|
|
if (
|
|
!dividerInserted &&
|
|
typeof dividerThresholdVersionId === 'number' &&
|
|
!(version.versionId > dividerThresholdVersionId)
|
|
) {
|
|
dividerInserted = true;
|
|
markup += '<div class="version-divider" role="presentation"></div>';
|
|
}
|
|
markup += renderRow(version, {
|
|
latestLibraryVersionId,
|
|
currentVersionId: normalizedCurrentVersionId,
|
|
modelId: record?.modelId ?? modelId,
|
|
});
|
|
return markup;
|
|
})
|
|
.join('');
|
|
|
|
const listContent =
|
|
rowsMarkup || renderFilteredEmptyState(currentBaseModelLabel);
|
|
|
|
container.innerHTML = `
|
|
${renderToolbar(record, {
|
|
displayMode,
|
|
isFilteringActive,
|
|
})}
|
|
<div class="versions-list">
|
|
${listContent}
|
|
</div>
|
|
`;
|
|
|
|
setupMediaHoverInteractions(container);
|
|
}
|
|
|
|
async function loadVersions({ forceRefresh = false, eager = false } = {}) {
|
|
if (controller.isLoading) {
|
|
return;
|
|
}
|
|
if (!modelId) {
|
|
renderErrorState(container, translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.'));
|
|
return;
|
|
}
|
|
if (controller.hasLoaded && !forceRefresh) {
|
|
return;
|
|
}
|
|
|
|
controller.isLoading = true;
|
|
if (!eager) {
|
|
showLoading();
|
|
}
|
|
|
|
try {
|
|
const client = ensureClient();
|
|
const response = await client.fetchModelUpdateVersions(modelId, {
|
|
refresh: false,
|
|
});
|
|
if (!response?.success) {
|
|
throw new Error(response?.error || 'Request failed');
|
|
}
|
|
render(response.record);
|
|
} catch (error) {
|
|
console.error('Failed to load model versions:', error);
|
|
renderErrorState(container, error?.message);
|
|
} finally {
|
|
controller.isLoading = false;
|
|
}
|
|
}
|
|
|
|
async function refresh() {
|
|
await loadVersions({ forceRefresh: true });
|
|
}
|
|
|
|
async function handleToggleModelIgnore(button) {
|
|
if (!controller.record) {
|
|
return;
|
|
}
|
|
const client = ensureClient();
|
|
const nextValue = !controller.record.shouldIgnore;
|
|
button.disabled = true;
|
|
try {
|
|
const response = await client.setModelUpdateIgnore(modelId, nextValue);
|
|
if (!response?.success) {
|
|
throw new Error(response?.error || 'Request failed');
|
|
}
|
|
render(response.record);
|
|
const toastKey = nextValue
|
|
? 'modals.model.versions.toast.modelIgnored'
|
|
: 'modals.model.versions.toast.modelResumed';
|
|
const toastMessage = translate(
|
|
toastKey,
|
|
{},
|
|
nextValue ? 'Updates ignored for this model' : 'Update tracking resumed'
|
|
);
|
|
showToast(toastMessage, {}, 'success');
|
|
} catch (error) {
|
|
console.error('Failed to update model ignore state:', error);
|
|
showToast(error?.message || 'Failed to update ignore preference', {}, 'error');
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
function handleToggleVersionDisplayMode() {
|
|
displayMode =
|
|
displayMode === DISPLAY_FILTER_MODES.SAME_BASE
|
|
? DISPLAY_FILTER_MODES.ANY
|
|
: DISPLAY_FILTER_MODES.SAME_BASE;
|
|
if (!controller.record) {
|
|
return;
|
|
}
|
|
render(controller.record);
|
|
}
|
|
|
|
async function handleToggleVersionIgnore(button, versionId) {
|
|
if (!controller.record) {
|
|
return;
|
|
}
|
|
const client = ensureClient();
|
|
const targetVersion = controller.record.versions.find(v => v.versionId === versionId);
|
|
const nextValue = targetVersion ? !targetVersion.shouldIgnore : true;
|
|
button.disabled = true;
|
|
try {
|
|
const response = await client.setVersionUpdateIgnore(
|
|
modelId,
|
|
versionId,
|
|
nextValue
|
|
);
|
|
if (!response?.success) {
|
|
throw new Error(response?.error || 'Request failed');
|
|
}
|
|
render(response.record);
|
|
const updatedVersion = response.record.versions.find(v => v.versionId === versionId);
|
|
const toastKey = updatedVersion?.shouldIgnore
|
|
? 'modals.model.versions.toast.versionIgnored'
|
|
: 'modals.model.versions.toast.versionUnignored';
|
|
const toastMessage = translate(
|
|
toastKey,
|
|
{},
|
|
updatedVersion?.shouldIgnore ? 'Updates ignored for this version' : 'Version re-enabled'
|
|
);
|
|
showToast(toastMessage, {}, 'success');
|
|
} catch (error) {
|
|
console.error('Failed to toggle version ignore state:', error);
|
|
showToast(error?.message || 'Failed to update version preference', {}, 'error');
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function performDeleteVersion({
|
|
triggerButton,
|
|
confirmButton,
|
|
closeModal,
|
|
version,
|
|
}) {
|
|
if (!version?.filePath) {
|
|
console.warn('Missing file path for deletion.');
|
|
return;
|
|
}
|
|
|
|
if (triggerButton) {
|
|
triggerButton.disabled = true;
|
|
}
|
|
|
|
let confirmOriginalText = '';
|
|
if (confirmButton) {
|
|
confirmOriginalText = confirmButton.textContent;
|
|
confirmButton.disabled = true;
|
|
}
|
|
|
|
let deletionSucceeded = false;
|
|
|
|
try {
|
|
const client = ensureClient();
|
|
await client.deleteModel(version.filePath);
|
|
deletionSucceeded = true;
|
|
showToast(
|
|
translate('modals.model.versions.toast.versionDeleted', {}, 'Version deleted'),
|
|
{},
|
|
'success'
|
|
);
|
|
} catch (error) {
|
|
console.error('Failed to delete version:', error);
|
|
showToast(error?.message || 'Failed to delete version', {}, 'error');
|
|
} finally {
|
|
if (triggerButton && document.body.contains(triggerButton)) {
|
|
triggerButton.disabled = false;
|
|
}
|
|
|
|
if (
|
|
confirmButton &&
|
|
document.body.contains(confirmButton) &&
|
|
!deletionSucceeded
|
|
) {
|
|
confirmButton.disabled = false;
|
|
if (confirmOriginalText) {
|
|
confirmButton.textContent = confirmOriginalText;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!deletionSucceeded) {
|
|
return;
|
|
}
|
|
|
|
if (typeof closeModal === 'function') {
|
|
closeModal();
|
|
}
|
|
|
|
await refresh();
|
|
}
|
|
|
|
function showDeleteVersionModal(version, triggerButton) {
|
|
const modalRecord = modalManager?.getModal?.('deleteModal');
|
|
if (!modalRecord?.element) {
|
|
return false;
|
|
}
|
|
|
|
const deleteLabel = translate('modals.model.versions.actions.delete', {}, 'Delete');
|
|
const cancelLabel = translate('common.actions.cancel', {}, 'Cancel');
|
|
const title = translate('modals.model.versions.actions.delete', {}, 'Delete');
|
|
const confirmMessage = translate(
|
|
'modals.model.versions.confirm.delete',
|
|
{},
|
|
'Delete this version from your library?'
|
|
);
|
|
const versionName =
|
|
version.name ||
|
|
translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version');
|
|
const previewUrl =
|
|
version.previewUrl || '/loras_static/images/no-preview.png';
|
|
const metaMarkup = buildMetaMarkup(version);
|
|
|
|
const modalElement = modalRecord.element;
|
|
const originalMarkup = modalElement.innerHTML;
|
|
|
|
const content = `
|
|
<div class="modal-content delete-modal-content version-delete-modal">
|
|
<h2>${escapeHtml(title)}</h2>
|
|
<p class="delete-message">${escapeHtml(confirmMessage)}</p>
|
|
<div class="delete-model-info">
|
|
<div class="delete-preview">
|
|
<img src="${escapeHtml(previewUrl)}" alt="${escapeHtml(versionName)}" onerror="this.src='/loras_static/images/no-preview.png'">
|
|
</div>
|
|
<div class="delete-info">
|
|
<h3>${escapeHtml(versionName)}</h3>
|
|
${
|
|
version.baseModel
|
|
? `<p class="version-base-model">${escapeHtml(version.baseModel)}</p>`
|
|
: ''
|
|
}
|
|
${metaMarkup ? `<div class="version-meta">${metaMarkup}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="cancel-btn">${escapeHtml(cancelLabel)}</button>
|
|
<button class="delete-btn">${escapeHtml(deleteLabel)}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const cleanupHandlers = [];
|
|
|
|
modalManager.showModal(
|
|
'deleteModal',
|
|
content,
|
|
null,
|
|
() => {
|
|
cleanupHandlers.forEach(handler => {
|
|
try {
|
|
handler();
|
|
} catch (error) {
|
|
console.error('Failed to cleanup delete modal handler:', error);
|
|
}
|
|
});
|
|
cleanupHandlers.length = 0;
|
|
modalElement.innerHTML = originalMarkup;
|
|
delete modalElement.dataset.versionId;
|
|
}
|
|
);
|
|
|
|
modalElement.dataset.versionId = String(version.versionId ?? '');
|
|
|
|
const cancelButton = modalElement.querySelector('.cancel-btn');
|
|
const confirmButton = modalElement.querySelector('.delete-btn');
|
|
|
|
const closeModal = () => modalManager.closeModal('deleteModal');
|
|
|
|
if (cancelButton) {
|
|
const handleCancel = event => {
|
|
event.preventDefault();
|
|
closeModal();
|
|
};
|
|
cancelButton.addEventListener('click', handleCancel);
|
|
cleanupHandlers.push(() => {
|
|
cancelButton.removeEventListener('click', handleCancel);
|
|
});
|
|
}
|
|
|
|
if (confirmButton) {
|
|
const handleConfirm = async event => {
|
|
event.preventDefault();
|
|
await performDeleteVersion({
|
|
triggerButton,
|
|
confirmButton,
|
|
closeModal,
|
|
version,
|
|
});
|
|
};
|
|
confirmButton.addEventListener('click', handleConfirm);
|
|
cleanupHandlers.push(() => {
|
|
confirmButton.removeEventListener('click', handleConfirm);
|
|
});
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async function handleDeleteVersion(button, versionId) {
|
|
if (!controller.record) {
|
|
return;
|
|
}
|
|
const version = controller.record.versions.find(item => item.versionId === versionId);
|
|
if (!version) {
|
|
console.warn('Target version missing from record for delete:', versionId);
|
|
return;
|
|
}
|
|
|
|
if (showDeleteVersionModal(version, button)) {
|
|
return;
|
|
}
|
|
|
|
const confirmText = translate(
|
|
'modals.model.versions.confirm.delete',
|
|
{},
|
|
'Delete this version from your library?'
|
|
);
|
|
if (!window.confirm(confirmText)) {
|
|
return;
|
|
}
|
|
|
|
await performDeleteVersion({
|
|
triggerButton: button,
|
|
version,
|
|
});
|
|
}
|
|
|
|
async function handleDownloadVersion(button, versionId) {
|
|
if (!controller.record) {
|
|
return;
|
|
}
|
|
|
|
const version = controller.record.versions.find(item => item.versionId === versionId);
|
|
if (!version) {
|
|
console.warn('Target version missing from record for download:', versionId);
|
|
return;
|
|
}
|
|
|
|
button.disabled = true;
|
|
|
|
try {
|
|
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
|
|
versionName: version.name || `#${version.versionId}`,
|
|
});
|
|
|
|
if (success) {
|
|
await refresh();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to start direct download for version:', error);
|
|
} finally {
|
|
if (document.body.contains(button)) {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
container.addEventListener('click', async event => {
|
|
const toolbarAction = event.target.closest('[data-versions-action]');
|
|
if (toolbarAction) {
|
|
const action = toolbarAction.dataset.versionsAction;
|
|
switch (action) {
|
|
case 'toggle-model-ignore':
|
|
event.preventDefault();
|
|
await handleToggleModelIgnore(toolbarAction);
|
|
break;
|
|
case 'toggle-version-display-mode':
|
|
event.preventDefault();
|
|
handleToggleVersionDisplayMode();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const actionButton = event.target.closest('[data-version-action]');
|
|
if (actionButton) {
|
|
const row = actionButton.closest('.model-version-row');
|
|
if (!row) {
|
|
return;
|
|
}
|
|
const versionId = Number(row.dataset.versionId);
|
|
const action = actionButton.dataset.versionAction;
|
|
|
|
switch (action) {
|
|
case 'download':
|
|
event.preventDefault();
|
|
await handleDownloadVersion(actionButton, versionId);
|
|
break;
|
|
case 'delete':
|
|
event.preventDefault();
|
|
await handleDeleteVersion(actionButton, versionId);
|
|
break;
|
|
case 'toggle-ignore':
|
|
event.preventDefault();
|
|
await handleToggleVersionIgnore(actionButton, versionId);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const row = event.target.closest('.model-version-row.is-clickable');
|
|
if (!row) {
|
|
return;
|
|
}
|
|
if (event.target.closest('button')) {
|
|
return;
|
|
}
|
|
if (event.target.closest('.version-actions')) {
|
|
return;
|
|
}
|
|
if (event.target.closest('a')) {
|
|
return;
|
|
}
|
|
|
|
const targetUrl = row.dataset.civitaiUrl;
|
|
if (!targetUrl) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
window.open(targetUrl, '_blank', 'noopener,noreferrer');
|
|
});
|
|
|
|
return {
|
|
load: options => loadVersions(options),
|
|
refresh,
|
|
};
|
|
}
|