feat(versions): add base filter toggle UI and styling

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.
This commit is contained in:
Will Miao
2025-11-18 06:46:50 +08:00
parent 3661b11b70
commit 655157434e
13 changed files with 521 additions and 52 deletions

View File

@@ -938,6 +938,18 @@
"viewLocalVersions": "Alle lokalen Versionen anzeigen",
"viewLocalTooltip": "Demnächst verfügbar"
},
"filters": {
"label": "Basisfilter",
"state": {
"showAll": "Alle Versionen",
"showSameBase": "Gleiches Basismodell"
},
"tooltip": {
"showAllVersions": "Wechseln, um alle Versionen anzuzeigen",
"showSameBaseVersions": "Wechseln, um nur Versionen mit demselben Basismodell anzuzeigen"
},
"empty": "Keine Versionen entsprechen dem Filter für das aktuelle Basismodell."
},
"empty": "Noch keine Versionshistorie für dieses Modell vorhanden.",
"error": "Versionen konnten nicht geladen werden.",
"missingModelId": "Für dieses Modell ist keine Civitai-Model-ID vorhanden.",

View File

@@ -938,6 +938,18 @@
"viewLocalVersions": "View all local versions",
"viewLocalTooltip": "Coming soon"
},
"filters": {
"label": "Base filter",
"state": {
"showAll": "All versions",
"showSameBase": "Same base"
},
"tooltip": {
"showAllVersions": "Switch to showing all versions",
"showSameBaseVersions": "Switch to showing only versions that match the current base model"
},
"empty": "No versions match the current base model filter."
},
"empty": "No version history available for this model yet.",
"error": "Failed to load versions.",
"missingModelId": "This model is missing a Civitai model id.",

View File

@@ -938,6 +938,18 @@
"viewLocalVersions": "Ver todas las versiones locales",
"viewLocalTooltip": "Disponible pronto"
},
"filters": {
"label": "Filtro base",
"state": {
"showAll": "Todas las versiones",
"showSameBase": "Mismo modelo base"
},
"tooltip": {
"showAllVersions": "Cambiar para mostrar todas las versiones",
"showSameBaseVersions": "Cambiar para mostrar solo versiones del mismo modelo base"
},
"empty": "Ninguna versión coincide con el filtro del modelo base actual."
},
"empty": "Aún no hay historial de versiones para este modelo.",
"error": "No se pudieron cargar las versiones.",
"missingModelId": "Este modelo no tiene un ID de modelo de Civitai.",

View File

@@ -938,6 +938,18 @@
"viewLocalVersions": "Voir toutes les versions locales",
"viewLocalTooltip": "Bientôt disponible"
},
"filters": {
"label": "Filtre de base",
"state": {
"showAll": "Toutes les versions",
"showSameBase": "Même modèle de base"
},
"tooltip": {
"showAllVersions": "Passer à l'affichage de toutes les versions",
"showSameBaseVersions": "Passer à l'affichage des versions du même modèle de base"
},
"empty": "Aucune version ne correspond au filtre du modèle de base actuel."
},
"empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.",
"error": "Échec du chargement des versions.",
"missingModelId": "Ce modèle ne possède pas d'identifiant de modèle Civitai.",

View File

@@ -938,6 +938,18 @@
"viewLocalVersions": "הצג את כל הגרסאות המקומיות",
"viewLocalTooltip": "יגיע בקרוב"
},
"filters": {
"label": "מסנן בסיס",
"state": {
"showAll": "כל הגרסאות",
"showSameBase": "אותו מודל בסיס"
},
"tooltip": {
"showAllVersions": "החלף להצגת כל הגרסאות",
"showSameBaseVersions": "החלף להצגת גרסאות עם אותו מודל בסיס"
},
"empty": "אין גרסאות התואמות את המסנן של מודל הבסיס הנוכחי."
},
"empty": "אין עדיין היסטוריית גרסאות למודל זה.",
"error": "טעינת הגרסאות נכשלה.",
"missingModelId": "למודל זה אין מזהה מודל של Civitai.",

View File

@@ -938,6 +938,18 @@
"viewLocalVersions": "ローカルの全バージョンを表示",
"viewLocalTooltip": "近日対応予定"
},
"filters": {
"label": "ベースフィルター",
"state": {
"showAll": "すべてのバージョン",
"showSameBase": "同じベース"
},
"tooltip": {
"showAllVersions": "すべてのバージョンを表示する",
"showSameBaseVersions": "同じベースモデルのバージョンのみ表示する"
},
"empty": "現在のベースモデルフィルターに一致するバージョンがありません。"
},
"empty": "このモデルにはまだバージョン履歴がありません。",
"error": "バージョンの読み込みに失敗しました。",
"missingModelId": "このモデルにはCivitaiのモデルIDがありません。",

View File

@@ -938,6 +938,18 @@
"viewLocalVersions": "로컬 버전 모두 보기",
"viewLocalTooltip": "곧 제공 예정"
},
"filters": {
"label": "기본 필터",
"state": {
"showAll": "모든 버전",
"showSameBase": "같은 베이스"
},
"tooltip": {
"showAllVersions": "모든 버전을 표시하도록 전환",
"showSameBaseVersions": "같은 베이스 모델 버전만 표시하도록 전환"
},
"empty": "현재 베이스 모델 필터와 일치하는 버전이 없습니다."
},
"empty": "이 모델에는 아직 버전 기록이 없습니다.",
"error": "버전을 불러오지 못했습니다.",
"missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.",

View File

@@ -938,6 +938,18 @@
"viewLocalVersions": "Показать все локальные версии",
"viewLocalTooltip": "Скоро появится"
},
"filters": {
"label": "Фильтр по базе",
"state": {
"showAll": "Все версии",
"showSameBase": "Тот же базовый"
},
"tooltip": {
"showAllVersions": "Переключиться на отображение всех версий",
"showSameBaseVersions": "Переключиться на отображение только версий с тем же базовым"
},
"empty": "Нет версий, соответствующих текущему фильтру базовой модели."
},
"empty": "Для этой модели пока нет истории версий.",
"error": "Не удалось загрузить версии.",
"missingModelId": "У этой модели отсутствует идентификатор модели Civitai.",

View File

@@ -938,6 +938,18 @@
"viewLocalVersions": "查看所有本地版本",
"viewLocalTooltip": "敬请期待"
},
"filters": {
"label": "基础筛选",
"state": {
"showAll": "全部版本",
"showSameBase": "相同基模型"
},
"tooltip": {
"showAllVersions": "切换为显示所有版本",
"showSameBaseVersions": "仅显示与当前基模型匹配的版本"
},
"empty": "没有与当前基模型筛选匹配的版本。"
},
"empty": "该模型还没有版本历史。",
"error": "加载版本失败。",
"missingModelId": "该模型缺少 Civitai 模型 ID。",

View File

@@ -938,6 +938,18 @@
"viewLocalVersions": "檢視所有本地版本",
"viewLocalTooltip": "敬請期待"
},
"filters": {
"label": "基礎篩選",
"state": {
"showAll": "所有版本",
"showSameBase": "相同基礎模型"
},
"tooltip": {
"showAllVersions": "切換為顯示所有版本",
"showSameBaseVersions": "僅顯示與目前基礎模型相符的版本"
},
"empty": "沒有符合目前基礎模型篩選的版本。"
},
"empty": "此模型尚無版本歷史。",
"error": "載入版本失敗。",
"missingModelId": "此模型缺少 Civitai 模型 ID。",

View File

@@ -24,12 +24,29 @@
color: var(--text-color);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.versions-toolbar-info p {
margin: 0;
font-size: 0.85rem;
color: var(--text-muted);
}
.versions-toolbar-info-heading {
display: flex;
align-items: center;
gap: var(--space-2);
}
.versions-toolbar-actions {
display: flex;
flex-wrap: wrap;
@@ -68,6 +85,41 @@
color: var(--text-color);
}
.versions-filter-toggle {
appearance: none;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: 0;
margin-bottom: 4px;
width: 30px;
height: 30px;
background: color-mix(in oklch, var(--card-bg) 80%, var(--bg-color));
align-self: center;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease, transform 0.2s ease;
position: relative;
cursor: pointer;
}
.versions-filter-toggle i {
font-size: 1rem;
}
.versions-filter-toggle:hover:not(:disabled) {
border-color: var(--text-color);
color: var(--text-color);
transform: translateY(-1px);
}
.versions-filter-toggle[data-filter-active="true"] {
border-color: color-mix(in oklch, var(--lora-accent) 65%, transparent);
color: var(--lora-accent);
background: color-mix(in oklch, var(--lora-accent) 20%, var(--card-bg) 80%);
}
.versions-toolbar-btn:disabled {
opacity: 0.6;
cursor: not-allowed;

View File

@@ -152,6 +152,81 @@ 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);
@@ -314,7 +389,7 @@ function getLatestLibraryVersionId(record) {
return Math.max(...record.inLibraryVersionIds);
}
function renderToolbar(record) {
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');
@@ -325,10 +400,23 @@ function renderToolbar(record) {
'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">
<h3>${translate('modals.model.versions.heading', {}, 'Model versions')}</h3>
<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">
@@ -353,6 +441,20 @@ function renderEmptyState(container) {
`;
}
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 = `
@@ -391,6 +493,8 @@ export function initVersionsTab({
record: null,
};
let displayMode = getDefaultDisplayMode();
let apiClient;
function ensureClient() {
@@ -414,55 +518,92 @@ 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);
let dividerInserted = false;
const sortedVersions = [...record.versions].sort(
(a, b) => Number(b.versionId) - Number(a.versionId)
);
const rowsMarkup = sortedVersions
.map(version => {
const isNewer =
typeof latestLibraryVersionId === 'number' &&
version.versionId > latestLibraryVersionId;
let markup = '';
if (
!dividerInserted &&
typeof latestLibraryVersionId === 'number' &&
!isNewer
) {
dividerInserted = true;
markup += '<div class="version-divider" role="presentation"></div>';
}
markup += renderRow(version, {
latestLibraryVersionId,
currentVersionId: normalizedCurrentVersionId,
modelId: record?.modelId ?? modelId,
});
return markup;
})
.join('');
container.innerHTML = `
${renderToolbar(record)}
<div class="versions-list">
${rowsMarkup}
</div>
`;
setupMediaHoverInteractions(container);
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;
@@ -531,6 +672,17 @@ export function initVersionsTab({
}
}
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;
@@ -799,9 +951,17 @@ export function initVersionsTab({
const toolbarAction = event.target.closest('[data-versions-action]');
if (toolbarAction) {
const action = toolbarAction.dataset.versionsAction;
if (action === 'toggle-model-ignore') {
event.preventDefault();
await handleToggleModelIgnore(toolbarAction);
switch (action) {
case 'toggle-model-ignore':
event.preventDefault();
await handleToggleModelIgnore(toolbarAction);
break;
case 'toggle-version-display-mode':
event.preventDefault();
handleToggleVersionDisplayMode();
break;
default:
break;
}
return;
}

View File

@@ -28,7 +28,14 @@ vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
}));
const stateMock = { global: { settings: { autoplay_on_hover: false } } };
const stateMock = {
global: {
settings: {
autoplay_on_hover: false,
update_flag_strategy: 'any',
},
},
};
vi.mock(STATE_MODULE, () => ({
state: stateMock,
}));
@@ -59,6 +66,7 @@ describe('ModelVersionsTab media rendering', () => {
</div>
`;
stateMock.global.settings.autoplay_on_hover = false;
stateMock.global.settings.update_flag_strategy = 'any';
({ getModelApiClient } = await import(API_FACTORY_MODULE));
fetchModelUpdateVersions = vi.fn();
getModelApiClient.mockReturnValue({
@@ -146,4 +154,133 @@ describe('ModelVersionsTab media rendering', () => {
expect(imageElement?.getAttribute('src')).toBe(previewUrl);
expect(document.querySelector('.version-media video')).toBeFalsy();
});
it('shows a stable label with a short state indicator', async () => {
stateMock.global.settings.update_flag_strategy = 'any';
fetchModelUpdateVersions.mockResolvedValue({
success: true,
record: {
shouldIgnore: false,
inLibraryVersionIds: [5],
versions: [
{
versionId: 5,
name: 'base',
baseModel: 'SDXL',
previewUrl: '/api/lm/previews/v-base.png',
sizeBytes: 1024,
isInLibrary: true,
shouldIgnore: false,
},
],
},
});
const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE);
const controller = initVersionsTab({
modalId: 'model-versions-modal',
modelType: 'loras',
modelId: 321,
currentVersionId: 5,
});
await controller.load();
const toggleText = document.querySelector('.versions-filter-toggle .sr-only');
expect(toggleText?.textContent?.trim()).toBe('Base filter: All versions');
});
it('filters versions to the current base model when strategy is same_base', async () => {
stateMock.global.settings.update_flag_strategy = 'same_base';
fetchModelUpdateVersions.mockResolvedValue({
success: true,
record: {
shouldIgnore: false,
inLibraryVersionIds: [10],
versions: [
{
versionId: 10,
name: 'v1.0',
baseModel: 'SDXL',
previewUrl: '/api/lm/previews/v1.png',
sizeBytes: 1024,
isInLibrary: true,
shouldIgnore: false,
},
{
versionId: 11,
name: 'v1.1',
baseModel: 'Realistic',
previewUrl: '/api/lm/previews/v1-1.png',
sizeBytes: 2048,
isInLibrary: false,
shouldIgnore: false,
},
],
},
});
const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE);
const controller = initVersionsTab({
modalId: 'model-versions-modal',
modelType: 'loras',
modelId: 789,
currentVersionId: 10,
});
await controller.load();
expect(document.querySelectorAll('.model-version-row').length).toBe(1);
});
it('toggle button can switch to display all versions', async () => {
stateMock.global.settings.update_flag_strategy = 'same_base';
fetchModelUpdateVersions.mockResolvedValue({
success: true,
record: {
shouldIgnore: false,
inLibraryVersionIds: [10],
versions: [
{
versionId: 10,
name: 'v1.0',
baseModel: 'SDXL',
previewUrl: '/api/lm/previews/v1.png',
sizeBytes: 1024,
isInLibrary: true,
shouldIgnore: false,
},
{
versionId: 11,
name: 'v1.1',
baseModel: 'Realistic',
previewUrl: '/api/lm/previews/v1-1.png',
sizeBytes: 2048,
isInLibrary: false,
shouldIgnore: false,
},
],
},
});
const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE);
const controller = initVersionsTab({
modalId: 'model-versions-modal',
modelType: 'loras',
modelId: 987,
currentVersionId: 10,
});
await controller.load();
expect(document.querySelectorAll('.model-version-row').length).toBe(1);
const toggleButton = document.querySelector('[data-versions-action="toggle-version-display-mode"]');
expect(toggleButton).toBeTruthy();
const toggleTextBefore = document.querySelector('.versions-filter-toggle .sr-only');
expect(toggleTextBefore?.textContent?.trim()).toContain('Same base');
toggleButton?.click();
expect(document.querySelectorAll('.model-version-row').length).toBe(2);
const toggleTextAfter = document.querySelector('.versions-filter-toggle .sr-only');
expect(toggleTextAfter?.textContent?.trim()).toContain('All versions');
});
});